diff --git a/e2e/channels_flows_e2e_test.go b/e2e/channels_flows_e2e_test.go index 8fd4458..f7597c7 100644 --- a/e2e/channels_flows_e2e_test.go +++ b/e2e/channels_flows_e2e_test.go @@ -17,7 +17,7 @@ import ( // // Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据) // paste(opencode:直接 complete_login 贴 API key) -// manual(qoder:start_login 返回 manual_hint,再凭据完成) +// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成) // // 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。 func TestChannelsFlowsE2E(t *testing.T) { @@ -40,7 +40,7 @@ func TestChannelsFlowsE2E(t *testing.T) { fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`) // ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)---- case "/v1/models": - fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`) + fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`) case "/v1/chat/completions": fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) default: @@ -166,7 +166,9 @@ func TestChannelsFlowsE2E(t *testing.T) { t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody) } - // ---------- ③ manual(qoder):hint 明确 + 凭据完成 ---------- + // ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ---------- + // qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程 + // (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。 st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( `{"platform":"qoder","base_url":%q}`, plat.URL)) if st != 200 { @@ -176,9 +178,16 @@ func TestChannelsFlowsE2E(t *testing.T) { if st != 200 { t.Fatalf("qoder start_login status=%d", st) } - hint, _ := m["manual_hint"].(string) - if !strings.Contains(hint, "WASM") { - t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint) + // 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint), + // 且会话声明为 device-poll。 + if hint, _ := m["manual_hint"].(string); hint != "" { + t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint) + } + if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" { + t.Fatalf("qoder 会话 flow 应 device-poll: %v", m) + } + if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") { + t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL) } st, m = call("POST", "/api/plugins/http/channels/login/complete", `{"platform":"qoder","credential":"QD-E2E-KEY"}`) @@ -189,12 +198,12 @@ func TestChannelsFlowsE2E(t *testing.T) { for time.Now().Before(deadline) { resp, raw := g.do("GET", "/v1/models", "", true) modelsBody = raw - if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") { + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") { break } time.Sleep(150 * time.Millisecond) } - if !strings.Contains(modelsBody, "qoder-default") { - t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody) + if !strings.Contains(modelsBody, "qmodel_38max") { + t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody) } } diff --git a/internal/core/channels_authorize_test.go b/internal/core/channels_authorize_test.go new file mode 100644 index 0000000..5b7d1e6 --- /dev/null +++ b/internal/core/channels_authorize_test.go @@ -0,0 +1,182 @@ +package core + +import ( + "net/url" + "regexp" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、 +// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。 +// +// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定: +// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri) +// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致 +// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起 +// - redirect_uri 必须百分号编码(含 :// 与 :) +func TestCallbackAuthorizeUrls(t *testing.T) { + 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) + } + + start := func(t *testing.T, plat string) (string, map[string]interface{}) { + t.Helper() + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("%s start_login: %v", plat, err) + } + j := out.JSON.(map[string]interface{}) + u, _ := j["url"].(string) + return u, j + } + + t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) { + u, _ := start(t, "trae") + if u == "" { + t.Fatal("trae 未给出授权 URL") + } + if !strings.Contains(u, "auth_callback_url=") { + t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u) + } + if strings.Contains(u, "redirect_uri=") { + t.Fatalf("trae 不该有 redirect_uri 参数: %s", u) + } + for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} { + if !strings.Contains(u, want) { + t.Fatalf("trae 缺参数 %s: %s", want, u) + } + } + // 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接 + if strings.Contains(u, "authorization&") { + t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u) + } + // 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数, + // 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定: + // login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外 + // 还可能被风控拦;x_* 是客户端形态伪装。 + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("trae 授权 URL 无法解析: %v", err) + } + q := parsed.Query() + for _, k := range []string{ + "login_version", "auth_from", "login_channel", "plugin_version", + "auth_type", "client_id", "redirect", "login_trace_id", + "auth_callback_url", "machine_id", "device_id", + "x_device_id", "x_machine_id", "x_device_brand", "x_device_type", + "x_os_version", "x_app_version", "x_app_type", + } { + if q.Get(k) == "" { + t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u) + } + } + // 设备身份是 hex32,trace_id 是它们拼接后的末 16 位 + m, d := q.Get("machine_id"), q.Get("device_id") + if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) { + t.Fatalf("machine_id 应为 hex32: %q", m) + } + if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) { + t.Fatalf("device_id 应为 hex32: %q", d) + } + if m == d { + t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)") + } + joined := m + d + if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] { + t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got) + } + // 同一平台两次登录必须得到不同设备身份(否则多账号共用设备, + // 会被上游以「该设备已签到」拦截) + u2, _ := start(t, "trae") + q2, _ := url.Parse(u2) + if q2.Query().Get("device_id") == d { + t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)") + } + // Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点): + // 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。 + if cb := q.Get("auth_callback_url"); cb != "" { + if !strings.HasSuffix(cb, "/authorize") { + t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb) + } + if !strings.HasPrefix(cb, "http://127.0.0.1:") { + t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb) + } + } + }) + + t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) { + u, _ := start(t, "gemini") + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("授权 URL 无法解析: %v", err) + } + ru := parsed.Query().Get("redirect_uri") + if !strings.HasPrefix(ru, "http://localhost:") { + t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru) + } + // 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)—— + // 换成别的路径就是 redirect_uri_mismatch。 + if !strings.HasSuffix(ru, "/oauth-callback") { + t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru) + } + for _, k := range []string{"client_id", "response_type", "scope", "state"} { + if parsed.Query().Get(k) == "" { + t.Fatalf("gemini 缺参数 %s: %s", k, u) + } + } + // 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。 + if got := parsed.Query().Get("access_type"); got != "offline" { + t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got) + } + if got := parsed.Query().Get("prompt"); got != "consent" { + t.Fatalf("gemini prompt 应为 consent,实得 %q", got) + } + // 参数不得重复(通用分支曾再追加一次 redirect_uri) + if n := strings.Count(u, "redirect_uri="); n != 1 { + t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u) + } + if n := strings.Count(u, "client_id="); n != 1 { + t.Fatalf("client_id 出现 %d 次: %s", n, u) + } + }) + + t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) { + u, _ := start(t, "lobsterai") + if !strings.Contains(u, "/portal#/login?") { + t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u) + } + if strings.Contains(u, "/login&") { + t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u) + } + if !strings.Contains(u, "source=electron") { + t.Fatalf("lobsterai 缺 source=electron: %s", u) + } + // redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败 + if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") { + t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u) + } + }) + + t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) { + for _, plat := range []string{"trae", "gemini", "lobsterai"} { + u, _ := start(t, plat) + if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" { + t.Fatalf("%s 不应硬编码 8081: %s", plat, u) + } + if strings.Contains(u, "{port}") { + t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u) + } + } + }) +} diff --git a/internal/core/channels_callback_loopback_test.go b/internal/core/channels_callback_loopback_test.go new file mode 100644 index 0000000..ba43d20 --- /dev/null +++ b/internal/core/channels_callback_loopback_test.go @@ -0,0 +1,220 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "llmsproxy/internal/lua" +) + +// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同: +// +// Trae /authorize 不带 state(token 直接放 query 里) +// 有道 /auth/callback 必须带 state +// Gemini /oauth-callback 必须带 state +// +// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、 +// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。 +func TestCallbackPlatformsLoopback(t *testing.T) { + 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) + } + + start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) { + t.Helper() + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("%s start_login: %v", plat, err) + } + m = out.JSON.(map[string]interface{}) + s := m["session"].(map[string]interface{}) + return m, s["nonce"].(string), s["id"].(string) + } + + poll := func(t *testing.T, sid string) map[string]interface{} { + t.Helper() + 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) + } + return o.JSON.(map[string]interface{}) + } + + t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) { + _, _, sid := start(t, "trae") + // 交换端点指到假上游 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"Token":"ACCESS-NEW"}`)) + })) + defer ts.Close() + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL}, + }); err != nil { + t.Fatal(err) + } + // 重新起会话,拿到带 loopback 的 redirect_uri + m, nonce, sid := start(t, "trae") + ru, _ := m["redirect_uri"].(string) + if !strings.HasSuffix(ru, "/authorize") { + t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru) + } + // Trae 的回调**不带 state**,而是把 token 放 query 里 + q := url.Values{} + q.Set("refreshToken", "RT-1") + q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`) + resp, err := http.Get(ru + "?" + q.Encode()) + if err != nil { + t.Fatalf("打 loopback 接收器失败: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode) + } + st := waitState(t, poll, sid, "completed") + if st["state"] != "completed" { + t.Fatalf("应完成登录,实得 %v", st) + } + raw, ok, _ := ps.GetSecret("channels", "channels:trae") + if !ok { + t.Fatal("凭据未落库") + } + var cred map[string]interface{} + if err := json.Unmarshal([]byte(raw), &cred); err != nil { + t.Fatal(err) + } + if cred["access_token"] != "ACCESS-NEW" { + t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"]) + } + if cred["uid"] != "u1" { + t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"]) + } + _ = nonce + }) + + t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) { + m, nonce, sid := start(t, "gemini") + ru, _ := m["redirect_uri"].(string) + if !strings.HasSuffix(ru, "/oauth-callback") { + t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru) + } + if !strings.HasPrefix(ru, "http://localhost:") { + t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru) + } + // 错 state 必须被接收器拒(400),且不推进会话 + bad, err := http.Get(ru + "?code=C-1&state=WRONG") + if err != nil { + t.Fatal(err) + } + bad.Body.Close() + if bad.StatusCode != 400 { + t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode) + } + if st := poll(t, sid); st["state"] != "pending" { + t.Fatalf("错 state 不应推进会话,实得 %v", st) + } + // 正确 state → 完成(交换端点指到假上游) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + // 必须带 client_secret:只发 client_id 会 invalid_request, + // 症状正是「浏览器显示授权成功但账号不出现」 + if r.PostFormValue("client_secret") == "" { + t.Error("token 交换必须带 client_secret") + } + if r.PostFormValue("grant_type") != "authorization_code" { + t.Errorf("grant_type = %q", r.PostFormValue("grant_type")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`)) + })) + defer ts.Close() + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "gemini", + "callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"}, + }); err != nil { + t.Fatal(err) + } + good, err := http.Get(ru + "?code=C-1&state=" + nonce) + if err != nil { + t.Fatal(err) + } + good.Body.Close() + st := waitState(t, poll, sid, "completed") + if st["state"] != "completed" { + t.Fatalf("应完成登录,实得 %v", st) + } + raw, ok, _ := ps.GetSecret("channels", "channels:gemini") + if !ok { + t.Fatal("凭据未落库") + } + var cred map[string]interface{} + if err := json.Unmarshal([]byte(raw), &cred); err != nil { + t.Fatal(err) + } + if cred["access_token"] != "GA-1" { + t.Fatalf("access_token = %v", cred["access_token"]) + } + }) +} + +// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。 +func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{}, + sid, want string) map[string]interface{} { + t.Helper() + var st map[string]interface{} + for i := 0; i < 30; i++ { + st = poll(t, sid) + if st["state"] != "pending" { + return st + } + time.Sleep(100 * time.Millisecond) + } + return st +} + +// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符 +// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。 +func TestAuthorizeURLsHaveNoUnfilledPlaceholders(t *testing.T) { + 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) + } + for _, plat := range []string{"trae", "gemini", "lobsterai"} { + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + m := out.JSON.(map[string]interface{}) + for _, key := range []string{"url", "redirect_uri"} { + v, _ := m[key].(string) + if v == "" { + continue + } + if strings.Contains(v, "{") || strings.Contains(v, "nil") { + t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v) + } + } + } +} diff --git a/internal/core/channels_lobster_exchange_test.go b/internal/core/channels_lobster_exchange_test.go new file mode 100644 index 0000000..26fe322 --- /dev/null +++ b/internal/core/channels_lobster_exchange_test.go @@ -0,0 +1,75 @@ +package core + +import ( + "encoding/json" + "llmsproxy/internal/lua" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLobsterExchangePreservesIdentity(t *testing.T) { + var calls int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var b map[string]string + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + t.Error(err) + } + if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" { + t.Errorf("invalid exchange fields: %#v", b) + } + if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" { + t.Error("wrong headers") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`)) + })) + defer ts.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) + } + // The fixture changes only the transport destination, not the upstream contract. + if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.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) + } + nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string) + req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}} + out, err = ps.InvokeHandler("channels", "oauth_callback", req) + if err != nil { + t.Fatal(err) + } + if out.Status != 200 { + t.Fatalf("callback: %#v", out) + } + raw, ok, err := ps.GetSecret("channels", "channels:lobsterai") + if err != nil || !ok { + t.Fatal("missing credential", 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", "latest_keyfrom", "expires_at", "uid", "user_id"} { + if cred[k] == nil || cred[k] == "" { + t.Errorf("missing %s", k) + } + } + out, err = ps.InvokeHandler("channels", "oauth_callback", req) + if err != nil { + t.Fatal(err) + } + if out.Status != 409 || calls != 1 { + t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls) + } +} diff --git a/internal/core/channels_lobster_loopback_test.go b/internal/core/channels_lobster_loopback_test.go new file mode 100644 index 0000000..a86db57 --- /dev/null +++ b/internal/core/channels_lobster_loopback_test.go @@ -0,0 +1,165 @@ +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 +} diff --git a/internal/core/channels_models_remote_test.go b/internal/core/channels_models_remote_test.go new file mode 100644 index 0000000..c4c560a --- /dev/null +++ b/internal/core/channels_models_remote_test.go @@ -0,0 +1,201 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "llmsproxy/internal/lua" +) + +// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。 +// +// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退" +// (cline-models.ts)。故这里逐条锁定: +// - 远端有的以远端为准(顺序也照远端) +// - 远端没有的用兜底表补位 +// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空) +// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通) +type modelsHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + mu sync.Mutex + body string + code int + hits int +} + +func newModelsHarness(t *testing.T, platform, body string) *modelsHarness { + t.Helper() + h := &modelsHarness{t: t, body: body, code: 200} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + h.hits++ + code, body := h.code, h.body + h.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + fmt.Fprint(w, body) + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false) + // 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许, + // 不需要额外授权 —— 也就不需要碰内核的授权 API。 + // 把该平台的远端端点指到假上游 + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": platform, + "models_remote": map[string]interface{}{ + "endpoint0": h.ts.URL, + }, + }); err != nil { + t.Logf("set_endpoints(models_remote) 未支持: %v", err) + } + return h +} + +// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。 +func modelIDs(t *testing.T, got interface{}) []string { + t.Helper() + j := got.(map[string]interface{})["json"].(map[string]interface{}) + ms, _ := j["models"].([]interface{}) + ids := make([]string, 0, len(ms)) + for _, e := range ms { + ids = append(ids, e.(map[string]interface{})["id"].(string)) + } + return ids +} + +func TestRemoteModelCatalogMergesWithFallback(t *testing.T) { + // zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠 + body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}` + h := newModelsHarness(t, "zcode", body) + + t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) { + got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]interface{})["json"].(map[string]interface{}) + res := m["results"].([]interface{})[0].(map[string]interface{}) + if res["ok"] != true { + t.Fatalf("刷新应成功, 实得 %v", res) + } + // NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表) + ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"}) + if err != nil { + t.Fatal(err) + } + ids := modelIDs(t, ml) + joined := strings.Join(ids, ",") + if !strings.Contains(joined, "NEW-MODEL-X") { + t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids) + } + if !strings.Contains(joined, "GLM-5.3") { + t.Fatalf("兜底/远端共有项应保留: %v", ids) + } + // 远端与兜底重叠项不得重复 + seen := map[string]int{} + for _, id := range ids { + seen[id]++ + if seen[id] > 1 { + t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids) + } + } + }) + + t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) { + // 先成功一次建立缓存 + if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil { + t.Fatal(err) + } + // 再让上游 500 + h.mu.Lock() + h.code = 500 + h.body = `{}` + h.mu.Unlock() + got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}) + if err != nil { + t.Fatal(err) + } + res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{}) + if res["ok"] == true { + t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res) + } + ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"}) + ids := modelIDs(t, ml) + if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") { + t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids) + } + }) +} + +// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。 +// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 —— +// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。 +func TestPlatformModelsAreRealCatalogKeys(t *testing.T) { + 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) + } + got, err := ps.Call("channels", "provider_status", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{}) + if len(plats) != 12 { + t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats)) + } + realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"} + sawQoder := 0 + for _, e := range plats { + p := e.(map[string]interface{}) + id := p["id"].(string) + ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id}) + if err != nil { + t.Fatalf("平台 %s model_list: %v", id, err) + } + ids := modelIDs(t, ml) + if len(ids) == 0 { + t.Fatalf("平台 %s 无模型", id) + } + for _, mid := range ids { + // 占位名特征:以 -default 结尾 + if strings.HasSuffix(mid, "-default") { + t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid) + } + } + if id == "qoder" { + j := map[string]bool{} + for _, mid := range ids { + j[mid] = true + } + for _, want := range realQoder { + if j[want] { + sawQoder++ + } + } + } + } + if sawQoder == 0 { + t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名") + } +} diff --git a/internal/core/channels_trae_checkin_test.go b/internal/core/channels_trae_checkin_test.go new file mode 100644 index 0000000..4bf1a1d --- /dev/null +++ b/internal/core/channels_trae_checkin_test.go @@ -0,0 +1,111 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "llmsproxy/internal/lua" +) + +// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200, +// 但头不对就只是一个失败/风控响应。参考实现明确记录: +// +// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败); +// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**, +// 真正成功的是约 20 个客户端头; +// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字** +// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。 +// +// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。 +func TestTraeCheckinHeaders(t *testing.T) { + var got map[string]string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = map[string]string{} + for k := range r.Header { + got[k] = r.Header.Get(k) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":0}`)) + })) + defer ts.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) + } + // 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用) + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "trae", + "checkin": map[string]interface{}{"url": ts.URL}, + }); err != nil { + t.Fatal(err) + } + + // uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs) + const uid = "12345" + // credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login + // 会原样存进保险库,取用时 credentialOf 再解出 access_token。 + cred, _ := json.Marshal(map[string]interface{}{ + "access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef", + }) + body, _ := json.Marshal(map[string]string{ + "platform": "trae", "credential": string(cred), "account": "t", + }) + if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ + Method: "POST", Role: "admin", Body: string(body), + }); err != nil { + t.Fatal(err) + } + + out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ + Method: "POST", Role: "admin", Body: `{"platform":"trae"}`, + }) + if err != nil { + t.Fatalf("claim_daily: %v", err) + } + if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil { + t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j) + } + + t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) { + if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" { + t.Fatalf("Authorization 必须是 Cloud-IDE-JWT (Bearer 会 401),实得 %q", v) + } + }) + t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) { + // 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的 + if v := got["X-Device-Id"]; v != "707304999019651" { + t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v) + } + }) + t.Run("行3 客户端身份头齐全", func(t *testing.T) { + for k, want := range map[string]string{ + "X-Market-Client-Id": "VSCode 1.107.1", + "X-User-Region": "CN", + "Package-Type": "stable_cn", + "X-Lgw-Req-Sdk-Type": "3", + "X-Lscbd-Platform": "windows", + "App-Version": "0.1.52", + } { + if v := got[k]; v != want { + t.Errorf("头 %s = %q,期望 %q", k, v, want) + } + } + if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 { + t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"]) + } + if len(got["Vscode-Sessionid"]) != 64 { + t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"]) + } + if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" { + t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id") + } + }) +} diff --git a/internal/core/channels_trae_derive_test.go b/internal/core/channels_trae_derive_test.go new file mode 100644 index 0000000..e8527e6 --- /dev/null +++ b/internal/core/channels_trae_derive_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "llmsproxy/internal/lua" +) + +// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的 +// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto) +// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。 +// +// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。 +func TestTraeDeriveMatchesReference(t *testing.T) { + raw, err := os.ReadFile("trae_derive_expected.json") + if err != nil { + t.Skipf("缺差分数据(跳过): %v", err) + } + var cases []struct { + U string `json:"u"` + Devid string `json:"devid"` + Market string `json:"market"` + Sess string `json:"sess"` + } + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + + 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) + } + + for _, tc := range cases { + got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U}) + if err != nil { + t.Fatalf("probe %q: %v", tc.U, err) + } + wrapped, _ := got.(map[string]interface{}) + m, _ := wrapped["json"].(map[string]interface{}) + if m == nil { + t.Fatalf("probe %q 无 json: %#v", tc.U, got) + } + if fmt.Sprint(m["devid"]) != tc.Devid { + t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid) + } + if fmt.Sprint(m["market"]) != tc.Market { + t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market) + } + if fmt.Sprint(m["sess"]) != tc.Sess { + t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess) + } + } + _ = lua.HandlerRequest{} +} diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go index c27f687..6b6e895 100644 --- a/internal/core/channels_trae_test.go +++ b/internal/core/channels_trae_test.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "fmt" "io" "net/http" @@ -115,10 +116,26 @@ func TestTraeCallbackBehavior(t *testing.T) { if st != 200 { t.Fatalf("应 200: %d %s", st, body) } - // 凭据必须是 ExchangeToken 的新 Token,不是回调原值 + // 凭据必须是 ExchangeToken 的新 Token,不是回调原值。 + // 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。 v, _, _ := h.ps.GetSecret("channels", "channels:trae") - if !strings.HasPrefix(v, "ACCESS-") { - t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v) + var cred map[string]interface{} + if err := json.Unmarshal([]byte(v), &cred); err != nil { + t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v) + } + if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") { + t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at) + } + // uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到 + if cred["uid"] != "u1" { + t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"]) + } + if cred["enterprise_id"] != "t1" { + t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"]) + } + // 设备身份来自登录会话(授权 URL 里下发的那对 hex32) + if d, _ := cred["device_id"].(string); len(d) != 32 { + t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d) } }) @@ -131,8 +148,12 @@ func TestTraeCallbackBehavior(t *testing.T) { t.Fatalf("应 200: %d %s", st, body) } v, _, _ := h.ps.GetSecret("channels", "channels:trae") - if v != "JWT-TOKEN-1" { - t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v) + var cred2 map[string]interface{} + if err := json.Unmarshal([]byte(v), &cred2); err != nil { + t.Fatalf("凭据应为 JSON: %v (%q)", err, v) + } + if cred2["access_token"] != "JWT-TOKEN-1" { + t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"]) } if atomic.LoadInt32(&h.exchHit) != before { t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)") @@ -208,7 +229,7 @@ func TestTraeCallbackPoolPath(t *testing.T) { // ① 凭据必须落在池账号的 ref v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid) if !ok || v == "" { - t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid) + t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid) } // ② account_list 必须报 has_credential=true lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ diff --git a/internal/core/trae_derive_expected.json b/internal/core/trae_derive_expected.json new file mode 100644 index 0000000..41641ca --- /dev/null +++ b/internal/core/trae_derive_expected.json @@ -0,0 +1,44 @@ +[ + { + "u": "12345", + "devid": "707304999019651", + "market": "5daa819c-8425-4bae-88b7-2bbfec3c25ba", + "sess": "e5b8dfc5830ce0bdb141f7177effb90a7cf8227bb23b930a427a8550bf7357df" + }, + { + "u": "UID-abc", + "devid": "579362202930608", + "market": "a5388fc1-5969-4666-bc74-be9a11f69567", + "sess": "a47da2e13fe16d49dc859fbed58d1d99e0faef5d116acafc64a2d26013d17a1f" + }, + { + "u": "900000001", + "devid": "764948639142479", + "market": "e449daa5-b0c0-4f35-918e-8bb01b33f179", + "sess": "fdd37c37d1cc950439c25467e6dca75fcc84a2b0d294aa910387a9a965645650" + }, + { + "u": "x", + "devid": "268405722745284", + "market": "4f2427c5-0583-4f82-8e18-4e38566dd857", + "sess": "79be40fff0fbaee572d1ba3ab13a47af9d2f4d943115ef512c56e42132e23a66" + }, + { + "u": "", + "devid": "170789623621479", + "market": "b32897c1-f9c0-4ad2-b2b4-725455fffb6b", + "sess": "4a61a6b4c4bcbf1040bf0edf3df2b3e7d76a956f3ef81ef8f148eadcc03d25cc" + }, + { + "u": "用户1", + "devid": "263026789643405", + "market": "1c1b4e1f-9d7a-4698-9f34-b057d9a74b0b", + "sess": "a13dbde68977eee49818e2e2fb7d3bbb1ffb4c807a6e6f1831168a84adea0dfe" + }, + { + "u": "1234567890123456789012345", + "devid": "358669731114854", + "market": "449882ec-9a69-4a7a-b9ab-25aa59b28c50", + "sess": "305eb96fe14a194386c0fd4148d4919870ed803b69cf75296b53ea1f41e4c574" + } +] diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 55a47ed..66a2612 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -15,9 +15,9 @@ -- ------------------------ -- 内核提供基础设施,插件自己不实现这些: -- --- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) --- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) --- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) +-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) +-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) +-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) -- -- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 -- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— @@ -25,11 +25,11 @@ -- -- 权限(本插件需要,全部可在设置页看到"申请中") -- ------------------------------------------------ --- add_source 把平台端点注册为上游源 --- list_sources 显示当前已接入的平台 --- probe_all 接入后立刻验证可达 --- reset_health 上游恢复后清冷却 --- status 面板显示运行状态 +-- add_source 把平台端点注册为上游源 +-- list_sources 显示当前已接入的平台 +-- probe_all 接入后立刻验证可达 +-- reset_health 上游恢复后清冷却 +-- status 面板显示运行状态 -- -- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 -- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 @@ -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", "model_list", "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" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "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" } @@ -61,26 +61,25 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda -- ============================================================================ -- -- 每个平台声明登录方式与各端点。字段: --- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ --- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ --- paste(用户粘贴 cookie/token) --- login_url 用户要在自己浏览器打开的地址 --- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) --- adapter 内核适配器名(openai / anthropic / …) --- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) --- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) +-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ +-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ +-- paste(用户粘贴 cookie/token) +-- login_url 用户要在自己浏览器打开的地址 +-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) +-- adapter 内核适配器名(openai / anthropic / …) +-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) +-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) -- -- 新增平台 = 加一条。不改内核,也不改本文件其它部分。 -- --- ⚠️ 真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 +-- 注意:真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 -- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== - -- - -- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 - -- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 + -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 + -- 注意:占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。 -- 夹具由测试经 channels/inject_platforms 端点运行时注入。 @@ -91,8 +90,30 @@ local PLATFORMS = { name = "TRAE(字节)", vendor = "bytedance", flow = "callback", - -- 用户在自己浏览器打开授权页;回调带 token 直传(auth_type=local)。 - login_url = "https://www.trae.cn/authorization", + -- 授权页(照参考 buildTraeLoginUrl 的完整参数集)。 + -- 注意参数名必须是 **auth_callback_url**(参考注释点名:早期用了 + -- redirect_uri/auth_callback 等错名,TRAE 拿不到回调地址、登录必然失败)。 + -- 端口用 {port} 占位,实际拼装时填本机监听端口。 + authorize = { + base = "https://www.trae.cn/authorization", + params = { + login_version = "1", auth_from = "solo", login_channel = "native_ide", + plugin_version = "2.3.62834", auth_type = "local", + client_id = "en1oxy7wnw8j9n", redirect = "0", + -- Trae 注册的回调路径是 /authorize(参考 trae-oauth.ts:565 + -- `http://127.0.0.1:${port}/authorize`)—— 换成别的路径授权页会拒绝。 + -- loopback 接收器会把它变成 http://127.0.0.1:{实际端口}/authorize。 + auth_callback_url = "{redirect_uri}", + -- 设备身份与反查凭据:参考 trae-oauth.ts 列为「一个都不能少」—— + -- login_trace_id 是回调**反查 pending** 的唯一凭据;machine_id/device_id + -- 缺席可能被风控拦;它们还会写进凭据供签到发 X-Device-Id(day 轮换躲 9074)。 + login_trace_id = "{trace_id}", + machine_id = "{machine_id}", device_id = "{device_id}", + x_device_id = "{device_id}", x_machine_id = "{machine_id}", + x_device_brand = "PC", x_device_type = "PC", x_os_version = "1.0", + x_app_version = "0.1.52", x_app_type = "stable", + }, + }, hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" }, base_url = "https://trae-api-cn.mchost.guru", adapter = "openai", @@ -104,6 +125,9 @@ local PLATFORMS = { checkin = { url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim", method = "POST", body = "{}", + -- 授权方案是 Cloud-IDE-JWT(不是 Bearer),且参考注释点明: + -- 简化头实测签到失败,必须带基于 user_id 派生的设备身份头(见 traeCheckinHeaders)。 + auth = "cloud-ide-jwt", }, -- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable}) checkin_status = { @@ -134,10 +158,10 @@ local PLATFORMS = { end, }, -- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed): - -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 - -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); - -- 无 refreshToken 用 userJwt.Token 兑底; - -- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。 + -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 + -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); + -- 无 refreshToken 用 userJwt.Token 兑底; + -- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。 callback = { mode = "trae", exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken", @@ -167,6 +191,12 @@ local PLATFORMS = { }, hosts = { "account.minimax.cn", "agent.minimax.cn" }, base_url = "https://agent.minimax.cn", + -- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH) + models_remote = { + endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } }, + auth = "bearer", + parse = "id_list", + }, adapter = "openai", models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed" }, -- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态) @@ -184,7 +214,7 @@ local PLATFORMS = { url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details", method = "GET", total_fn = function(rec) - -- ⚠️ total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 + -- 注意 total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end local sum = 0 for _, d in ipairs(rec.details) do @@ -197,10 +227,10 @@ local PLATFORMS = { -- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。 -- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调): - -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; - -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); - -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token - -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 + -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; + -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); + -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token + -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 { id = "cline", name = "Cline", @@ -225,6 +255,17 @@ local PLATFORMS = { }, hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" }, base_url = "https://api.cline.bot", + -- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。 + -- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由 + -- recommended-models 下发;只打前者会看不到任何免费模型。 + models_remote = { + endpoints = { + { url = "https://api.cline.bot/api/v1/ai/cline/recommended-models", method = "GET", url_auth = false }, + { url = "https://api.cline.bot/api/v1/models", method = "GET" }, + }, + auth = "bearer", + parse = "cline_merge", + }, adapter = "openai", -- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费) models = { @@ -270,6 +311,14 @@ local PLATFORMS = { }, hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, base_url = "https://copilot.tencent.com", + -- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。 + -- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。 + models_remote = { + endpoints = { { url = "https://copilot.tencent.com/v3/config", method = "GET" } }, + auth = "bearer", + headers_fn = true, + parse = "buddy_config", + }, adapter = "openai", -- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。 -- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。 @@ -315,11 +364,9 @@ local PLATFORMS = { end, }, -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) - -- - -- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 + -- -- 注意响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 -- data 不同),这是本接口最容易解析错的地方。 - -- - -- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 + -- -- 注意刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 -- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[] -- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回, -- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。 @@ -379,19 +426,43 @@ local PLATFORMS = { name = "Gemini(Cloud Code)", vendor = "google", flow = "callback", - login_url = "https://accounts.google.com/o/oauth2/v2/auth" - .. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - .. "&redirect_uri=http://127.0.0.1:8845/auth/callback" - .. "&response_type=code" - .. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email" - .. "&include_granted_scopes=true", + login_url = "https://accounts.google.com/o/oauth2/v2/auth", + -- login_url 已含完整 OAuth 参数(client_id/scope/response_type), + -- 只剩 redirect_uri 与 state 需要按会话填 —— 用 authorize 规格声明, + -- 避免通用分支再追加一遍参数(造成 redirect_uri 出现两次)。 + authorize = { + base = "https://accounts.google.com/o/oauth2/v2/auth", + params = { + client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + -- 回调路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH), + -- 主机是 localhost 而非 127.0.0.1(参考注释明确);路径/主机改一个就是 + -- redirect_uri_mismatch。 + redirect_uri = "http://localhost:{port}/oauth-callback", + response_type = "code", + -- 六项 scope 逐字照抄参考 GEMINI_SCOPES(cloud-platform 必需) + scope = "openid https://www.googleapis.com/auth/cloud-platform " + .. "https://www.googleapis.com/auth/userinfo.email " + .. "https://www.googleapis.com/auth/userinfo.profile " + .. "https://www.googleapis.com/auth/cclog " + .. "https://www.googleapis.com/auth/experimentsandconfigs", + -- 参考三个固定参数(缺 access_type=offline 拿不到 refresh_token) + access_type = "offline", + prompt = "consent", + include_granted_scopes = "true", + }, + }, callback = { mode = "code", token_url = "https://oauth2.googleapis.com/token", + -- ⚠️ 必须带 client_secret:token 端点强制校验客户端身份,只发 client_id 会回 + -- invalid_request: client_secret is missing,症状正是「浏览器显示授权成功, + -- 但面板里账号一直不出现」。redirect_uri 必须与授权时**完全一致**(含主机名)。 token_body = "grant_type=authorization_code&code={code}" .. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - .. "&redirect_uri=http://127.0.0.1:8845/auth/callback", - pkce = true, -- 授权页带 verifier(S256),交换时回传 + .. "&client_secret=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" + .. "&redirect_uri=http://localhost:{port}/oauth-callback", + -- 参考明言:上游 Cloud Code 用的是**最朴素的 authorization_code + + -- client_secret**,刻意不带 PKCE。 }, hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" }, base_url = "https://cloudcode-pa.googleapis.com", @@ -405,14 +476,58 @@ local PLATFORMS = { name = "LobsterAI(有道)", vendor = "youdao", flow = "callback", - login_url = "https://lobsterai.youdao.com/login", + -- 授权页真值:{portal}/portal#/login?source=electron&redirect_uri=<编码>&state= + -- (参考 buildLobsteraiLoginUrl)。注意 login 在 **hash 段**,且三个参数都是 + -- query —— 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当 + -- 首个分隔符(应为 ?),授权页必然报参数错误。 + -- 授权页真值(照参考 buildLobsteraiLoginUrl):login 在 **hash 段**, + -- 三个 query 参数是 source/redirect_uri/state。 + -- 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当首个 + -- 分隔符(应为 ?),授权页必然报参数错误。 + login_url = "https://lobsterai.youdao.com/portal#/login", + authorize = { + hash = "https://lobsterai.youdao.com/portal#/login", + params = { + source = "electron", + -- 参考注释:**必须**是 http://127.0.0.1:{port}/auth/callback 形态, + -- 登录页会校验。此处的 redirect_uri 由**本机 loopback 接收器**决定 + -- (不是本插件的 /api/plugins/http 挂载点,也不是用户机器的 localhost), + -- 故用 {redirect_uri} 占位,拼装时由会话的实际接收器地址填入。 + redirect_uri = "{redirect_uri}", + }, + }, callback = { mode = "code", - token_url = "https://lobsterai-server.youdao.com/api/auth/login", - token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}', + -- 参考真值 LOBSTERAI_EXCHANGE_PATH。此前写的 /api/auth/login 是我编的。 + token_url = "https://lobsterai-server.youdao.com/api/auth/exchange", + -- 请求体必须含这 5 个字段(照参考 exchangeLobsteraiAuthCode)。 + -- 兜底模板(正常路径走 lobsteraiExchange 助手,它会动态解析版本号)。 + token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"{now}","uuid":"{uuid}","version":"2026.9.4"}', + }, + -- 交换端点不需要 Authorization(此时还没有 token),但要这套匿名头。 + callback_headers = { + ["User-Agent"] = "LobsterAI/0.1.0", + }, + hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com", + "api-overmind.youdao.com" }, + -- 客户端版本号:exchange 的 `version` 与签到 query 都必填。 + -- **不能写死**:2026-10-13 实测该端点返回 2026.9.23,而参考的兜底常量 + -- 还停在 2026.9.4 —— 写死就会发出一个过期的版本号。 + -- 响应形态非统一信封:外层无 code,值在 data.value.version。 + client_version = { + url = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update", + path = "data.value.version", + fallback = "2026.9.4", }, - hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" }, base_url = "https://lobsterai-server.youdao.com", + -- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH) + models_remote = { + -- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。 + -- 此前写的 api.lobster.ai 是我编的(DNS 都不解析)。 + endpoints = { { url = "https://lobsterai-server.youdao.com/api/models/available", method = "GET" } }, + auth = "bearer", + parse = "id_list", + }, adapter = "openai", models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" }, -- 余额(对齐参考:GET /api/user/profile-summary) @@ -433,6 +548,12 @@ local PLATFORMS = { vendor = "opencode", flow = "paste", base_url = "https://opencode.ai/zen", + -- 远端模型目录(照参考 opencode-product.ts modelsPath) + models_remote = { + endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } }, + auth = "bearer", + parse = "id_list", + }, adapter = "openai", models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" }, }, @@ -457,18 +578,17 @@ local PLATFORMS = { }, -- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。 - -- - -- ⚠️ 纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。 + -- -- 注意纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。 -- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 —— -- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做: - -- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256 - -- &nonce&machine_id&client_id - -- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 - -- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 + -- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256 + -- &nonce&machine_id&client_id + -- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 + -- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 -- 三个必须照抄的细节(参考注释都点了名): - -- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝) - -- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401) - -- · nonce/verifier 必须原样回传(PKCE 校验) + -- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝) + -- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401) + -- · nonce/verifier 必须原样回传(PKCE 校验) { id = "qoder", name = "Qoder", @@ -492,7 +612,17 @@ local PLATFORMS = { }, base_url = "https://api2-v2.qoder.sh", adapter = "openai", - models = { "qoder-default" }, + -- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。 + -- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的 + -- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。 + -- 此前这里写的是 "qoder-default"(我编的占位名,上游不认,选了也调不通)。 + -- 远端目录端点 /api/v2/model/list 需 WASM 签名,故参考自己也用兜底表。 + models = { + "auto", "ultimate", "performance", "efficient", "smodel", "cmodel", + "qmodel_38max", "qfmodel", "qmodel_latest", "qmodel", + "kmodel_latest", "kmodel", "gmodel", "gfmodel", + "dmodel", "dfmodel", "mmodel", + }, -- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining -- + addOnQuota.remaining,走 openApiBase) balance = { @@ -508,23 +638,22 @@ local PLATFORMS = { }, -- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。 - -- - -- ⚠️ 纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考 + -- -- 注意纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考 -- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路 -- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着 -- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」: - -- ① POST {origin}/api/v1/oauth/cli/init - -- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)> - -- body {provider:"bigmodel"} - -- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec} - -- ② 用户在浏览器打开 authorize_url 完成授权 - -- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} - -- Authorization: Bearer <同一个 flow_secret> - -- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai} + -- ① POST {origin}/api/v1/oauth/cli/init + -- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)> + -- body {provider:"bigmodel"} + -- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec} + -- ② 用户在浏览器打开 authorize_url 完成授权 + -- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} + -- Authorization: Bearer <同一个 flow_secret> + -- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai} -- 判据(参考注释点名,与官方一致): - -- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试 - -- · code != 0 或 data 缺失 → pending - -- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一 + -- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试 + -- · code != 0 或 data 缺失 → pending + -- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一 { id = "zcode", name = "ZCode", @@ -542,6 +671,13 @@ local PLATFORMS = { -- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测); -- 只有订阅制才走 api.z.ai。这里用积分制那条。 base_url = "https://zcode.z.ai", + -- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL) + models_remote = { + endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET", + query = "app_version=1.0.0&platform=unknown" } }, + auth = "none", + parse = "zcode_configs", + }, endpoint = "/api/v1/zcode-plan/anthropic/v1/messages", adapter = "anthropic", -- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL) @@ -554,7 +690,10 @@ local PLATFORMS = { return tonumber(d.balance or d.remaining or d.credits or d.total) end, }, - models = { "zcode-default" }, + -- 兜底目录(照参考 ZCODE_FALLBACK_MODELS)。远端可拉: + -- GET /api/v1/client/configs?app_version=...&platform=unknown + -- 此前写的 "zcode-default" 是我编的占位名,上游不认。 + models = { "GLM-5.3", "GLM-5.3-Flash" }, }, -- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。 @@ -563,16 +702,16 @@ local PLATFORMS = { id = "loomy", name = "Loomy", vendor = "loomy", - -- ⚠️ 纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。 + -- 注意纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。 -- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**: - -- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid - -- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染) - -- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_ - -- 响应体里 wx_errcode=NNN / wx_code='...' - -- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders) + -- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid + -- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染) + -- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_ + -- 响应体里 wx_errcode=NNN / wx_code='...' + -- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders) -- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"): - -- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧); - -- 403=用户取消;402=二维码失效 + -- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧); + -- 403=用户取消;402=二维码失效 flow = "wechat-poll", wechat = { -- appid / redirect_uri 照参考常量(原写的 appid 是我编的,上游不认); @@ -586,7 +725,9 @@ local PLATFORMS = { -- 逐字照参考 WECHAT_UA(微信对空 UA / 爬虫 UA 可能拒绝) user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" .. " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - account_base = "https://account.xfyun.cn", + -- 参考真值 LOOMY_ACCOUNT_BASE。此前写的 account.xfyun.cn 是我编的 + -- (DNS 都不解析),实际账号域是 xfinfr.com —— 拼音近似导致的误写。 + account_base = "https://account.xfinfr.com", bind_path = "/login/thirdAccount/bind/auth", access_key_id = "2thryby66wxi53sk", access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r", @@ -594,7 +735,11 @@ local PLATFORMS = { }, base_url = "https://loomy.xunfei.cn", adapter = "openai", - models = { "loomy-default" }, + -- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。 + models = { + "deepseek-v4-flash-0731", "MiniMax-M3", "Kimi-k2.6", "qwen-3.8-max", + "GLM-5.3-Flash", "qwen3.8-flash", "spark-x", "mimo-v2.5", + }, }, -- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。 @@ -603,11 +748,11 @@ local PLATFORMS = { id = "raccoon", name = "Raccoon(商汤小浣熊)", vendor = "sensetime", - -- ⚠️ 纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**: - -- ① 本地造 qrcode_code(16 字节随机 hex) - -- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染) - -- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code} - -- → data.status ∈ pending/logging/canceled/success;success 带 token + -- 注意纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**: + -- ① 本地造 qrcode_code(16 字节随机 hex) + -- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染) + -- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code} + -- → data.status ∈ pending/logging/canceled/success;success 带 token -- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入 -- pending),故完全绕开收不到的自定义协议回调。 -- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号 @@ -624,7 +769,13 @@ local PLATFORMS = { -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) base_url = "https://xiaohuanxiong.com/api/web/llm/v2", adapter = "openai", - models = { "raccoon-default" }, + -- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。 + -- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。 + models = { + "sn-sensenova-6-8-flash", "sn-sensenova-6-8-flash-lite", + "sn-glm-5-3", "sn-kimi-k3", "sn-glm-5-3-flash", + "sn-deepseek-v4-1-flash", + }, -- 余额(照参考:GET {apiBase}/api/web/points/v1/balance) balance = { url = "https://xiaohuanxiong.com/api/web/points/v1/balance", @@ -722,6 +873,9 @@ local function declaredHosts() if p.qr then addHost(p.qr.api_base) end if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end + if p.models_remote then + for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end + end if p.balance then addHost(p.balance.url) end if p.checkin_status then addHost(p.checkin_status.url) end if p.callback then addHost(p.callback.exchange_url) end @@ -750,21 +904,21 @@ end -- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go): -- -- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier) --- 的 base64url(参考 createMinimaxPkce)。 +-- 的 base64url(参考 createMinimaxPkce)。 -- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken): --- HTTP 200 + status=pending → 继续等 --- HTTP 200 + status=slow_down → interval += 5s,继续等 --- HTTP 200 + status=denied / --- access_denied → 会话 failed(用户拒绝) --- HTTP 200 + status=expired / --- expired_token → 会话 failed(码过期) --- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态) --- 非 200 + error=slow_down → interval += 5s,继续等 --- HTTP 200(无上述状态) → 成功,提取 access_token --- 其它 → 会话 failed(带 error 文本) --- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。 +-- HTTP 200 + status=pending → 继续等 +-- HTTP 200 + status=slow_down → interval += 5s,继续等 +-- HTTP 200 + status=denied / +-- access_denied → 会话 failed(用户拒绝) +-- HTTP 200 + status=expired / +-- expired_token → 会话 failed(码过期) +-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态) +-- 非 200 + error=slow_down → interval += 5s,继续等 +-- HTTP 200(无上述状态) → 成功,提取 access_token +-- 其它 → 会话 failed(带 error 文本) +-- 注意「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。 -- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环: --- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 +-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 -- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。 local function b64url(s) @@ -882,9 +1036,9 @@ local function devicePkce() end -- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。 --- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. } --- 或 { ok=false, reason="denied"|"expired", message=.. } --- done=false payload=nil(还在等:pending / slow_down) +-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. } +-- 或 { ok=false, reason="denied"|"expired", message=.. } +-- done=false payload=nil(还在等:pending / slow_down) -- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新 --(slow_down 后 interval 增大,与参考一致)。 local function deviceOneStep(plat, sess) @@ -917,10 +1071,10 @@ local function deviceOneStep(plat, sess) if not ok or type(rec) ~= "table" then rec = {} end -- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调): - -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 - -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); - -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); - -- access_denied / expired_token / invalid_grant 同组判失败。 + -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 + -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); + -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); + -- access_denied / expired_token / invalid_grant 同组判失败。 -- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。 local workos = (d.judge == "workos") local status = rec.status @@ -928,8 +1082,7 @@ local function deviceOneStep(plat, sess) local step = tonumber(d.slow_down_step) or 5 -- 「404/pending = 继续轮询」形态(Qoder)。 - -- - -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而 + -- -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而 -- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等" -- 而非失败。把 404 当失败会让登录永远失败。 -- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。 @@ -1164,6 +1317,9 @@ end local PERSIST_KEYS = { "accounts", "pool", "modelDisabled", "settings", "providerOrder", "log", "injected_platforms", + -- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。 + -- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。 + "remoteModels", } local function persistState() @@ -1361,9 +1517,9 @@ end -- -- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点 -- (Bearer 头、HMAC 签名)无需关心存的是哪种形态: --- 裸 token → 原样返回 --- 完整凭据 JSON 字符串 → 取 .access_token --- 已经是 table → 取 .access_token +-- 裸 token → 原样返回 +-- 完整凭据 JSON 字符串 → 取 .access_token +-- 已经是 table → 取 .access_token -- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。 local function credentialOf(ref) local raw = ref and __llmsproxy_secrets.get(ref) @@ -1453,9 +1609,9 @@ end -- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。 -- 对齐参考 absoluteExpiryMs: --- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串 --- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底 --- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底) +-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串 +-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底 +-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底) -- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt; -- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。 local function absExpiryMs(rec, absKey, relKey, accessToken) @@ -1482,8 +1638,8 @@ end -- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。 -- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData: --- access_token / refresh_token / expires_at / refresh_expires_at / --- token_type / scope / domain / user_id / nickname / enterprise_id / account_type +-- access_token / refresh_token / expires_at / refresh_expires_at / +-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type -- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。 -- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。 local function buildCredential(data, accountData) @@ -1550,6 +1706,155 @@ local function credentialIsRefreshable(cred) end -- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true } +-- ============================================================================ +-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback) +-- ============================================================================ +-- +-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名), +-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更), +-- 那时平台就"没有模型可用"了。 +-- +-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts +-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。 +-- +-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。 + +-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。 +local function effectiveModels(provider) + local plat = platformById(provider) + if not plat then return {} end + local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider] + local out, seen = {}, {} + -- 远端在前(权威顺序) + if type(remote) == "table" and type(remote.ids) == "table" then + for _, id in ipairs(remote.ids) do + if type(id) == "string" and id ~= "" and not seen[id] then + seen[id] = true out[#out+1] = id + end + end + end + -- 兜底补位(只补远端没有的) + for _, id in ipairs(plat.models or {}) do + if not seen[id] then seen[id] = true out[#out+1] = id end + end + return out +end + +-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。 +-- 每个分支的字段路径都照参考实现,含注释点名的坑。 +local function parseRemoteModels(kind, body) + local ok, rec = pcall(json.decode, body or "") + if not ok or type(rec) ~= "table" then return {} end + local out = {} + + if kind == "id_list" then + -- OpenAI 风格 {data:[{id}]};也有平台直接给数组或 {models:[...]} + local arr = rec.data or rec.models or rec + if type(arr) == "table" then + for _, m in ipairs(arr) do + local id = type(m) == "table" and m.id or (type(m) == "string" and m) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "buddy_config" then + -- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。 + -- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。 + local d = rec.data or rec + local arr = d.models or d.agents or rec.models + if type(arr) == "table" then + for _, m in ipairs(arr) do + local id = type(m) == "table" and (m.id or m.modelId or m.name) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "zcode_configs" then + -- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象** + -- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。 + -- Lua 里表无此区别,但要同时支持数组与 map 两形态。 + local raw = rec.data and rec.data.builtinModels + if type(raw) == "table" then + for _, m in pairs(raw) do + local id = type(m) == "table" and (m.modelId or m.id) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "cline_merge" then + -- 照参考 cline-models.ts:**必须同时打两个端点**。 + -- recommended-models 是**唯一权威的 free 集合**;/api/v1/models 的 460 个 + -- id 里根本没有 cline-free/*。故这里把它们合并去重。 + local function pushFrom(node) + if type(node) ~= "table" then return end + local arr = node.data or node + if type(arr) ~= "table" then return end + for _, m in ipairs(arr) do + local id = type(m) == "table" and m.id or (type(m) == "string" and m) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + -- free 数组优先在前(它含 cline-free/* 与 stealth/*) + pushFrom(rec.free) + pushFrom(rec) + end + + return out +end + +-- refreshRemoteModels(provider):拉取并缓存该平台的远端目录。 +-- 返回 (ids, err);**任何失败都不清空既有缓存**(宁可留旧目录也别让平台变空)。 +local function refreshRemoteModels(provider) + local plat = platformById(provider) + if not plat then return nil, "未知平台" end + local mr = plat.models_remote + if not mr then return nil, "该平台无远端目录端点" end + -- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉) + local tok + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local t = credentialOf(a.credentialRef) + if t and t ~= "" then tok = t break end + end + end + if (not tok or tok == "") and mr.auth ~= "none" then return nil, "无可用凭据" end + + local merged, seen = {}, {} + local lastErr + for _, ep in ipairs(mr.endpoints or {}) do + local url = ep.url + if ep.query then url = url .. "?" .. ep.query end + local headers = { ["Accept"] = "application/json" } + local useAuth = ep.url_auth ~= false and mr.auth ~= "none" + if useAuth and tok and tok ~= "" then + headers["Authorization"] = "Bearer " .. tok + end + -- 平台级身份头(buddy 必须带 X-Product-Code 等,否则会被当成另一个产品) + if mr.headers_fn and plat.headers_fn then + local extra = plat.headers_fn(tok or "", nil) + for k, v in pairs(extra or {}) do headers[k] = v end + end + local resp, err = __llmsproxy_http({ + url = url, method = ep.method or "GET", headers = headers, + }) + if err or not resp or resp.status ~= 200 then + lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) + else + for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do + if not seen[id] then seen[id] = true merged[#merged+1] = id end + end + end + end + if #merged == 0 then return nil, lastErr or "远端返回空目录" end + if not plugin.state.remoteModels then plugin.state.remoteModels = {} end + plugin.state.remoteModels[provider] = { + ids = merged, + fetched_at = os.time(), + } + markDirty() + return merged, nil +end + local function disabledModelsOf(provider) if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end local m = plugin.state.modelDisabled[provider] @@ -1601,7 +1906,8 @@ local function syncAccountSource(provider, accountId) local name = accountSourceName(provider, accountId) local models = {} local disabled = disabledModelsOf(provider) - for _, m in ipairs(plat.models or {}) do + -- 用**有效目录**(远端优先、兜底补位),不是硬编码表 + for _, m in ipairs(effectiveModels(provider)) do if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end @@ -1659,6 +1965,9 @@ local function sweepSessions() local now = os.time and os.time() or 0 for id, s in pairs(sessions()) do if s.expires_at and s.expires_at < now then + -- 会话过期时要顺带回收它为回调起的 loopback 接收器 —— 否则用户放弃 + -- 登录后那些 python 监听会一直挂着(进程泄漏,且白占端口)。 + reapLoopback(id) sessions()[id] = nil end end @@ -1759,6 +2068,41 @@ end -- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。 -- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。 -- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。 +-- buildAuthorizeUrl(plat, port, state, redirectUri, vars):按平台 authorize 规格拼授权 URL。 +-- +-- 规格形如 { base = "...", params = {...}, hash = "/portal#/login" }: +-- - params 里的值支持 {port} / {redirect_uri} 占位,另由 vars 提供 +-- 平台特有的占位(如 Trae 的 machine_id / device_id / trace_id); +-- - hash 非空时把参数挂在 **hash 段**之后(LobsterAI 是 /portal#/login?…); +-- - 值一律百分号编码(redirect_uri 含 :// 与 :,不编码会被登录页拒)。 +-- 参考注释明确警告过手工拼串漏编码的坑(buildLobsteraiLoginUrl)。 +local function buildAuthorizeUrl(plat, port, state, redirectUri, vars) + local a = plat.authorize or {} + local base = a.hash or a.base or plat.login_url + local parts = {} + local function add(k, v) + v = tostring(v):gsub("{port}", tostring(port)) + if redirectUri and redirectUri ~= "" then + v = v:gsub("{redirect_uri}", redirectUri) + end + if type(vars) == "table" then + for vk, vv in pairs(vars) do + v = v:gsub("{" .. vk .. "}", tostring(vv)) + end + end + if k == "state" and (v == "" or v == "nil") then v = state end + parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v) + end + -- 确定性的顺序:排序键名,保证同一会话每次拼出的 URL 一致(便于对账/复现) + local keys = {} + for k in pairs(a.params or {}) do keys[#keys + 1] = k end + table.sort(keys) + for _, k in ipairs(keys) do add(k, a.params[k]) end + -- state 始终补上(除非 params 里已显式给了) + if not (a.params and a.params.state) then add("state", state) end + return base .. "?" .. table.concat(parts, "&") +end + local function gatewayPort() -- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同) local s = plugin.state.settings and plugin.state.settings.gateway_port @@ -1893,19 +2237,214 @@ local function cdpEval(cdpPort, expr) return rec.value, nil end +-- ============================================================================ +-- loopback 回调接收器(平台要求 redirect_uri 落在本机精确路径时用) +-- ============================================================================ +-- +-- 为什么必需:各回调平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost) +-- 且路径是各自注册值(有道 /auth/callback、Trae /authorize、Gemini /oauth-callback), +-- 而本插件的挂载点在 `/api/plugins/http/…`;更关键的是**远程用户机器上的 +-- 127.0.0.1 不是本网关** —— 那样重定向根本打不到这里。 +-- +-- 做法:插件(可信、有 OS 能力)在本机起一个极小的 HTTP 监听,路径前缀就是该 +-- 平台的注册路径。**双栈绑回环**(::1 与 127.0.0.1 同一端口):参考注释点明 +-- 「浏览器常把 localhost 解析成 IPv6」,而 Gemini 注册的就是 http://localhost:… +-- —— 只绑 IPv4 会让那条回调连不上(但**绝不**绑 :: 对外暴露)。 +-- 它把回调 query 原子写进**文件**(不拼 shell,避免引号/特殊字符注入 —— +-- 与 CDP 桥用文件传表达式同一理由),然后给浏览器一个可关闭的页面。 +-- +-- 校验规则按平台形态分两类(不能一刀切): +-- * Trae:回调把 token 直接放 query(refreshToken/userJwt/…),**不带 state** +-- (参考 parseTraeCallback 从不读 state),故对 /authorize 不要求 state。 +-- * Gemini / 有道:标准授权码回调,state 必须匹配,否则 400(防伪造回调)。 +-- +-- 真正入账前的 state 终校在 poll_login(读文件后再次比对 nonce)。 +-- +-- 返回 (redirect_url, nil) 或 (nil, err)。进程 pid 记入 plugin.state.loopbacks, +-- 会话结束时由 reapLoopback 回收。 +local LOOPBACK_PY = [[ +import sys, json, os, socket, tempfile, threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs +prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。 +require_state = (prefix != "/authorize") +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + u = urlparse(self.path) + if not u.path.startswith(prefix): + self.send_response(404); self.end_headers(); self.wfile.write(b"Not found"); return + q = parse_qs(u.query) + state = (q.get("state") or [""])[0] + if require_state and (not state or (want_state and state != want_state)): + # state 缺失/不匹配 → 不是本次登录发起的(或伪造),直接拒(对齐参考)。 + self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8") + self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u53c2\u6570\u65e0\u6548".encode()); return + # 把整个回调 query 原样转成 JSON(含 code/authCode 与 Trae 的 + # refreshToken/userJwt/userInfo 等),由 Lua 侧按平台形态取用。 + payload = {} + for k, v in q.items(): + payload[k] = v[0] if v else "" + code = payload.get("code") or payload.get("authCode") or "" + if require_state and not code: + self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8") + self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return + if not payload: + self.send_response(400); self.end_headers(); self.wfile.write(b"No params"); return + # 不经 shell 拼串,直接写成 JSON 文件(避免引号/特殊字符注入)。 + d = os.path.dirname(out_path) + fd, tmp = tempfile.mkstemp(dir=d) + with os.fdopen(fd, "w") as f: + json.dump(payload, f) + os.replace(tmp, out_path) + self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8") + self.end_headers() + self.wfile.write("

\u767b\u5f55\u6210\u529f\uff0c\u53ef\u4ee5\u5173\u95ed\u6b64\u7a97\u53e3\u4e86

".encode()) + # 双栈:两个 HTTP 实例都要主动停(否则非当前那个会挂着不退)。 + def _stop(): + for s in SERVERS: + try: s.shutdown() + except Exception: pass + threading.Thread(target=_stop, daemon=True).start() + +SERVERS = [] +# 为何要双栈:参考注释点明「浏览器常把 localhost 解析成 IPv6」—— +# 只绑 127.0.0.1 时,Gemini 的 http://localhost:…/oauth-callback 会连不上。 +# 不能改成绑 ::(那等于对外暴露);只绑回环地址的两种家族。 +# 注意:address_family 必须在**构造前**就在类上定好(socketserver 建 socket +# 时读的就是这个类属性);构造后再赋值无效 —— 那会让 IPv6 分支静默失败。 +class V6Server(HTTPServer): + address_family = socket.AF_INET6 + +port = 0 +try: + v6 = V6Server(("::1", 0), H) + port = v6.server_address[1] + SERVERS.append(v6) +except Exception: + pass +try: + v4 = HTTPServer(("127.0.0.1", port), H) + if port == 0: + port = v4.server_address[1] + SERVERS.append(v4) +except Exception: + pass +if not SERVERS: + sys.exit(3) +# 把实际选中的端口写回文件:调用方读它拼 redirect_uri(bind 0 时端口只有 bind 后才知道)。 +with open(port_path, "w") as f: + f.write(str(port)) +for s in SERVERS: + threading.Thread(target=s.serve_forever, daemon=True).start() +threading.Event().wait() +]] + +local function loopbackScriptPath() + local p = "/tmp/llmsproxy-loopback.py" + local w = io.open(p, "w") + if not w then return nil end + w:write(LOOPBACK_PY) + w:close() + return p +end + +-- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。 +-- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码, +-- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。 +local function reapLoopback(sessionId) + local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId] + if not l then return end + if l.pid and l.pid ~= "" then + os.execute("kill -9 " .. tostring(l.pid) .. " 2>/dev/null") + end + if l.file then os.remove(l.file) end + if l.port_file then os.remove(l.port_file) end + plugin.state.loopbacks[sessionId] = nil +end + +-- loopbackReceiver(session_id, cb_path):起接收器,返回该会话的 redirect_uri。 +-- +-- cb_path 必须与平台注册值**逐字一致**(改了就是 redirect_uri_mismatch): +-- 有道 /auth/callback(参考注释点名「精确路径」)、 +-- Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)。 +local function loopbackReceiver(sessionId, cbPath) + cbPath = cbPath or "/auth/callback" + local script = loopbackScriptPath() + if not script then return nil, "无法写入 loopback 脚本" end + local dir = storeDir() + local outFile = dir .. "/callback-" .. sessionId .. ".json" + local portFile = dir .. "/callback-" .. sessionId .. ".port" + os.remove(outFile) + os.remove(portFile) + local sess = sessions()[sessionId] + local wantState = (sess and sess.nonce) or "" + -- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面); + -- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。 + local cmd = ("python3 %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format( + script, cbPath, outFile, wantState, portFile, sessionId) + local h = io.popen(cmd) + local pid = h and h:read("*l") + if h then h:close() end + if not pid or pid == "" then return nil, "loopback 启动失败(python3 不可用?)" end + if not plugin.state.loopbacks then plugin.state.loopbacks = {} end + plugin.state.loopbacks[sessionId] = { pid = pid, file = outFile, port_file = portFile } + -- 等端口文件出现(就绪探针,不用 sleep 拍脑袋) + local deadline = (os.time and os.time() or 0) + 10 + local port = nil + while true do + local f = io.open(portFile, "r") + if f then + local v = f:read("*l") + f:close() + port = tonumber(v) + if port then break end + end + if (os.time and os.time() or 0) >= deadline then + reapLoopback(sessionId) + return nil, "loopback 未就绪(未取得端口)" + end + os.execute("sleep 0.2") + end + -- 主机用 127.0.0.1(不是 localhost,除非平台注册值就是 localhost); + -- 路径按平台注册值一字不差。 + local host = (sess and sess.callback_host) or "127.0.0.1" + return "http://" .. host .. ":" .. tostring(port) .. cbPath, nil +end + +-- readLoopbackCallback(session_id):读接收器写下的回调 query。 +-- 返回 (payload_table, nil) 或 (nil, nil)。未到/文件不存在返回 nil,nil —— +-- 调用方应继续保持 pending(不能把「还没回调」当成「失败」)。 +local function readLoopbackCallback(sessionId) + local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId] + local f = l and l.file and io.open(l.file, "r") + if not f then return nil, nil end + local body = f:read("*a") + f:close() + local ok, rec = pcall(json.decode, body or "") + if not ok or type(rec) ~= "table" then return nil, nil end + return rec, nil +end + +-- ============================================================================ -- spawnBrowser 拉起一组服务端浏览器。返回 --- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。 --- start_url 是浏览器要打开的登录页;callback_path 是拼 redirect_uri 用的路径。 +-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。 -- -- 与早期实现的差别(都是真实踩过的坑): --- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 → --- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。 --- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据 --- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。 --- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。 --- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包), --- 而不是一个含糊的"进程启动失败"。 -local function spawnBrowser(start_url, callback_path) +-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 → +-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。 +-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据 +-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。 +-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。 +-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包), +-- 而不是一个含糊的"进程启动失败"。 +-- spawnBrowser(start_url):拉起服务端浏览器栈(Xvfb + chromium + x11vnc + +-- websockify),返回 VNC 视图地址与 CDP 端口。 +-- start_url 已是**拼装完整的授权页 URL**(redirect_uri/state 由调用方按平台 +-- 规格填好),此处不再改写。 +local function spawnBrowser(start_url) local b = browserStack() if b.session then return nil, "已有浏览器会话在跑,请先停止" end local deps = browserDeps() @@ -1919,8 +2458,7 @@ local function spawnBrowser(start_url, callback_path) b.seq = b.seq + 1 local seq = b.seq -- display / 端口取号:**探测空闲**,不能按 seq 取模。 - -- - -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X-lock)在 + -- -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X-lock)在 -- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接 -- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来, -- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。 @@ -1966,10 +2504,12 @@ local function spawnBrowser(start_url, callback_path) -- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次 local pass = sha256_hex(newToken()):sub(1, 16) local gwPort = gatewayPort() - -- 端口占位换成网关真实回环地址(浏览器在服务端,回调直接命中网关挂载点) - local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", - "redirect_uri=127.0.0.1:" .. gwPort .. callback_path) - + -- 不再改写 start_url 里的端口/回调地址:`{port}` 占位已在 buildAuthorizeUrl + -- 拼装时换成 gatewayPort(),而各平台的回调路径**彼此不同**(Trae 用 + -- auth_callback_url 指本插件挂载点、LobsterAI 用 /auth/callback 指 loopback + -- 接收器)。此前这里用 gsub 把 "redirect_uri=127.0.0.1:" 一律改写成 + -- /api/plugins/http/channels/callback —— 一旦命中就会把需要别的路径的平台 + -- (尤其有道,其 login 段还被百分号编码)改错。现为死代码且只可能致害,遂删。 -- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。 local pids = {} local function spawn(name, cmd, host, port, timeoutMs) @@ -2054,20 +2594,24 @@ end -- ============================================================================ -- start_login 按优先级阶梯响应: --- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 --- 用户自己的机器上,插件轮询/收回调拿凭据) --- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), --- 无需用户之外的动作 --- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 --- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 -function plugin.start_login(req) - local args = bodyOf(req) - local plat = platformById(args and args.platform) - if not plat then - return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } - end - sweepSessions() - local now = os.time and os.time() or 0 +-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 +-- 用户自己的机器上,插件轮询/收回调拿凭据) +-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), +-- 无需用户之外的动作 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 +-- +-- 有道 LobsterAI:portal 授权页要求 redirect_uri 是 +-- http://127.0.0.1:{port}/auth/callback(**精确路径**,参考注释点名)。 +-- 远程用户机器上的 127.0.0.1 **不是本网关** —— portal 重定向打不到本插件的 +-- /api/plugins/http/... 挂载点。故插件(可信、有 OS 能力)在本机起一个 +-- 轻量的 loopback 接收器,portal 重定向落到它的本地端口;它把 authCode/state +-- 写进一个文件,poll_login 读该文件完成交换。这样远程用户在服务端浏览器(VNC) +-- 里完成交互后,portal 直接重定向到该本机端口,无需远程浏览器与网关同机。 + +local function initLoginSession(plat, args, now) local sess = { id = newToken():sub(1, 24), platform = plat.id, @@ -2078,8 +2622,35 @@ function plugin.start_login(req) created_at = now, expires_at = now + SESSION_TTL, } + if plat.id == "lobsterai" then + local seed = newToken() + sess.install_uuid = seed:sub(1, 8) .. "-" .. seed:sub(9, 12) .. "-4" + .. seed:sub(14, 16) .. "-8" .. seed:sub(18, 20) .. "-" .. seed:sub(21, 32) + sess.first_keyfrom = tostring(now * 1000) + elseif plat.id == "trae" then + -- 设备身份:参考生成两个 **hex32**(照 login.sh 的 `openssl rand -hex 16`), + -- 并在登录 URL 里下发。凭据里也要留一份(签到 X-Device-Id 用), + -- 且**每账号必须不同** —— 同一天两账号共用会被「该设备已签到」拦。 + -- 此前只发 5 个参数、完全没造这两个值,登录页就停在「授权中」不回来。 + sess.machine_id = newToken():sub(1, 32) + sess.device_id = newToken():sub(33, 64) + local joined = sess.machine_id .. sess.device_id + sess.login_trace_id = #joined >= 16 and joined:sub(-16) or joined + end sessions()[sess.id] = sess - sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号 + sess.account_id = args and args.account_id + return sess +end + +function plugin.start_login(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then + return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } + end + sweepSessions() + local now = os.time and os.time() or 0 + local sess = initLoginSession(plat, args, now) logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")") -- 响应里只带一次 nonce(回调校验要用),存侧不回显 @@ -2088,6 +2659,28 @@ function plugin.start_login(req) state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at }, platform = plat.id, } + if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" then + -- 这三个平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost), + -- 且路径各自是注册值:有道 /auth/callback、Trae /authorize、 + -- Gemini /oauth-callback。回环地址由「打开授权页的那台机器」解析,而 + -- 服务端浏览器就在本机 → 重定向落本机 → 插件自己的接收器能接住它。 + -- 远程用户在自己电脑上打开则永远接不住(这正是 callback 流在服务器 + -- 部署下必须走服务端浏览器的原因)。 + local cbPath = (plat.authorize and plat.authorize.callback_path) + or (plat.id == "gemini" and "/oauth-callback") + or (plat.id == "trae" and "/authorize") or "/auth/callback" + -- Gemini 注册的 redirect_uri 主机是 **localhost**(不是 127.0.0.1), + -- 参考注释明确点名;两者在多数机器上等价,但必须与注册值逐字一致。 + if plat.id == "gemini" then sess.callback_host = "localhost" end + local cb_url, cb_err = loopbackReceiver(sess.id, cbPath) + if cb_err then + sess.state = "failed" + out.error = "无法启动回调接收器: " .. cb_err + return { status = 200, json = out } + end + sess.redirect_uri = cb_url + out.redirect_uri = cb_url + end if plat.flow == "manual" then -- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。 sess.state = "failed" @@ -2121,7 +2714,7 @@ function plugin.start_login(req) logEvent("state 已申请 " .. plat.id) elseif plat.flow == "vnc" then -- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。 - local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback") + local bs, berr = spawnBrowser(string.format(plat.login_url, 0)) if berr then out.browser_error = berr out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖" @@ -2162,9 +2755,9 @@ function plugin.start_login(req) end local html = resp.body or "" -- 两条路径互为兜底(照参考 extractLoomyWechatUuid 的正则): - -- 主: - -- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=" - -- ⚠️ 无需执行 JS —— 这是本方案能脱离浏览器的基础。 + -- 主: + -- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=" + -- 关键:无需执行 JS —— 这是本方案能脱离浏览器的基础。 local uuid = html:match("/connect/qrcode/([%w_%-=+/]+)") or html:match("l/qrconnect%?uuid=([%w_%-=+/]+)") if not uuid or uuid == "" then @@ -2219,14 +2812,13 @@ function plugin.start_login(req) logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12)) elseif plat.flow == "device-poll" then -- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。 - -- - -- 两种形态: - -- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code, - -- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。 - -- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把 - -- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页, - -- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。 - -- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。 + -- -- 两种形态: + -- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code, + -- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。 + -- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把 + -- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页, + -- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。 + -- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。 local verifier, challenge = devicePkce() local machineId = newToken():sub(1, 32) local dm = plat.device.code_method or "POST" @@ -2276,9 +2868,60 @@ function plugin.start_login(req) out.session.interval = sess.device_interval logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code)) else - -- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点 + -- ①②:URL 交给用户自己的浏览器。 + -- + -- callback 流程的授权 URL 构造有三条硬性要求(此前都错): + -- 1) **每个平台的参数集不同**,不能套通用模板:Trae 用 auth_callback_url + -- (不是 redirect_uri)、Gemini 用标准 OAuth 四参数、LobsterAI 的 login + -- 在 **hash 段**(/portal#/login)。 + -- 2) 端口必须用**本机实际监听端口**,不能硬编码 8081(非默认部署会全错)。 + -- 3) redirect_uri 必须百分号编码(含 :// 与 :,不编码会被授权页拒)。 + local port = gatewayPort() if plat.flow == "callback" then - out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/http/channels/callback&state=" .. sess.nonce + if type(plat.authorize) == "table" then + local vars + if sess.machine_id then + vars = { machine_id = sess.machine_id, device_id = sess.device_id, + trace_id = sess.login_trace_id } + end + out.url = buildAuthorizeUrl(plat, port, sess.nonce, sess.redirect_uri, vars) + else + local cb = "http://localhost:" .. tostring(port) .. "/api/plugins/http/channels/callback" + local sep = plat.login_url:find("?", 1, true) and "&" or "?" + out.url = plat.login_url .. sep .. "redirect_uri=" .. rfc3986Escape(cb) + .. "&state=" .. sess.nonce + end + -- callback 流的 redirect_uri 是**回环地址**(127.0.0.1 / localhost)。 + -- 回环地址由「打开授权页的那台机器」解析:用户在自己电脑上打开时,重定向 + -- 落在**用户机器**,回调永远到不了本网关 —— 这是服务器部署下 callback 流 + -- 无法靠「本机浏览器」完成的根本原因(CodeBuddy 能用的原因是它走 + -- state-poll,根本不需要回调)。 + -- + -- 故 callback 流要走**服务端浏览器**:授权页在网关本机打开(经 VNC 呈现给 + -- 运维交互),重定向落本机回环,回调命中本插件的 loopback 接收器/挂载点。 + -- 没有浏览器栈时**如实说明**,不假装这个 URL 在用户机器上能用。 + local deps = browserDeps() + if deps.ok then + local bs, berr = spawnBrowser(out.url) + if not berr then + out.view_url = bs.view_url + out.session.cdp_port = bs.cdp_port + sess.cdp_port = bs.cdp_port + sess.collect_expr = plat.collect_expr or plat.callback_collect_expr + out.server_browser = true + logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")") + else + out.browser_error = berr + logEvent("服务端浏览器启动失败 " .. plat.id .. ": " .. berr) + end + else + local miss = {} + for _, m in ipairs(deps.missing) do miss[#miss + 1] = m.bin end + out.browser_error = "缺浏览器栈依赖: " .. table.concat(miss, ", ") + end + out.hint = out.server_browser + and "请在 VNC 窗口内完成授权(回调会落在网关本机)" + or "callback 流的回调是回环地址,只有与网关同机的浏览器才能完成;请先安装浏览器栈(channels/deps/status 可查缺什么)" else out.url = plat.login_url end @@ -2286,25 +2929,206 @@ function plugin.start_login(req) return { status = 200, json = out } end +-- lobsteraiClientVersion(plat):解析有道客户端版本号(带缓存与兜底)。 +-- +-- 三个消费点都要它:exchange 的 `version`、续期请求体的 `version`、签到 query。 +-- 集中一处避免各自拉取。照参考 LobsteraiVersionResolver: +-- - 远程成功 → 用远程值并缓存; +-- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。 +-- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。 +local function lobsteraiClientVersion(plat) + local cv = plat.client_version + if type(cv) ~= "table" then return "2026.9.4" end + local cache = plugin.state.clientVersion + local now = os.time and os.time() or 0 + if type(cache) == "table" and cache.value and cache.at + and (now - cache.at) < (cv.ttl or 3600) then + return cache.value + end + local resp, err = __llmsproxy_http({ + url = cv.url, method = "GET", + headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" }, + }) + if not err and resp and resp.status == 200 then + local ok, rec = pcall(json.decode, resp.body or "") + local v = ok and rec + -- 按 path(data.value.version)逐层下钻 + for seg in tostring(cv.path or ""):gmatch("[^%.]+") do + if type(v) ~= "table" then v = nil break end + v = v[seg] + end + -- 格式校验(照参考的日期式版本号正则):主干点分数字,可选预发布后缀。 + -- 不校验而直接采信:上游返 HTML/空值时会拼出一个更费解的失败。 + if type(v) == "string" and v:match("^%d+[%d%.]*[%w%.%-]*$") then + plugin.state.clientVersion = { value = v, at = now } + return v + end + logEvent("有道版本号格式异常,改用兜底: " .. tostring(v)) + end + -- 失败:兜底且不缓存 + return cv.fallback or "2026.9.4" +end + +-- lobsteraiExchange(plat, code, s):用 authCode 换有道凭据。 +-- +-- 同一段交换逻辑有两处入口:① 回调落到本插件挂载点(oauth_callback), +-- ② 回调落到本机 loopback 接收器、由 poll_login 读文件推进。两条路必须 +-- 逐字一致 —— 否则改一处漏一处,正是 go:embed 三连坑那种「同一 bug 多副面孔」。 +-- 故集中在此,返回 (credential_json, account, err)。 +local function lobsteraiExchange(plat, code, s) + if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end + local now = tostring(os.time() * 1000) + local resp, err = __llmsproxy_http({ + url = plat.callback.token_url, method = "POST", + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json", + ["User-Agent"] = "LobsterAI/0.1.0" }, + body = json.encode({ authCode = code, firstKeyfrom = s.first_keyfrom, + latestKeyfrom = now, uuid = s.install_uuid, version = lobsteraiClientVersion(plat) }), + }) + if err or not resp or resp.status ~= 200 then + return nil, nil, "LobsterAI exchange 请求失败: " .. tostring(err or (resp and resp.status)) + end + local ok, rec = pcall(json.decode, resp.body or "") + local d = ok and type(rec) == "table" and rec.data + if not (rec and type(rec) == "table" and rec.code == 0 and type(d) == "table" + and type(d.accessToken) == "string" and d.accessToken ~= "") then + -- 把上游的原因带出来(实测失败形如 code=40102 message="无效或已过期的授权码"), + -- 否则运维只看到「交换失败」而无法区分是码过期、参数错还是版本被拒。 + local why = "" + if ok and type(rec) == "table" then + why = tostring(rec.msg or rec.message or ("code=" .. tostring(rec.code))) + end + return nil, nil, "LobsterAI exchange 失败: " .. why + end + local u = type(d.user) == "table" and d.user or {} + local function nonempty(v) return type(v) == "string" and v ~= "" and v or nil end + -- uid 四级回退(照参考 resolveLobsteraiUid):user.id → userId → yid → token 摘要 + local uid = nonempty(u.id) or nonempty(u.userId) or nonempty(u.yid) + or sha256_hex(d.accessToken):sub(1, 16) + local account = nonempty(u.nickname) or uid + -- uuid/first_keyfrom 必须随凭据保存:续期请求体要原样回传(参考注释点名)。 + local cred = { access_token = d.accessToken, refresh_token = d.refreshToken or "", + uid = uid, user_id = nonempty(u.userId) or nonempty(u.yid) or "", nickname = account, + uuid = s.install_uuid, first_keyfrom = s.first_keyfrom, latest_keyfrom = now } + local exp = tonumber(d.expiresIn) + if exp and exp > 0 then cred.expires_at = tostring(os.time() * 1000 + exp * 1000) + else cred.expires_at = credentialExpiresAtMs(cred) end + return json.encode(cred), account, nil +end + -- plugin.poll_login(session_id) 由页面轮询:会话是否完成。 -- -- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep): -- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死 -- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。 -- --- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷): +-- 关键:**必须把 session 上的 account_id 传给 complete_login**(真实缺陷): -- 页面流程是「先 account/add 建占位账号(credentialRef=channels::) -- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走 -- 兼容旧路径,凭据落到 `channels:` 这个**与占位账号无关的键**,而账号池 -- 条目的 credentialRef 指向 `channels::` —— 于是 account/list 报 -- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法 -- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。 +-- 前向声明:这些实现都在本文件下方,而 poll_login / oauth_callback(更早的代码) +-- 就要调用它们。Lua 的 local 作用域看不到其后的定义,所以必须在**第一个调用点 +-- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作; +-- 改成 local 后这个隐式依赖就断了 —— 表现为 +-- attempt to call global 'traeCallback' (a nil value)。 +local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId +local traeCheckinHeaders, traeFixNickname + function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" sweepSessions() local s = sessions()[id] if not s then return { status = 200, json = { state = "gone" } } end + -- 回调流(callback)的凭据推进:授权页在**服务端浏览器**里打开,平台把 + -- 授权结果重定向回**本机回环地址**,由插件自己的 loopback 接收器(路径按各 + -- 平台注册值:有道 /auth/callback、Trae /authorize、Gemini /oauth-callback) + -- 接住并写下 {code,state}。这里读它并完成交换。 + -- + -- 为什么要服务端浏览器:redirect_uri 是回环地址,由「打开授权页的那台机器」 + -- 解析。远程用户在自己电脑上打开时,重定向落在**用户机器**上,永远到不了 + -- 本网关。CodeBuddy 之所以一直能用,是因为它走 state-poll,根本不需要回调。 + if s.flow == "callback" and s.state == "pending" and s.redirect_uri then + local cb, _ = readLoopbackCallback(s.id) + local gotState = cb and (cb.state or "") + local code = cb and (cb.code or cb.authCode or "") + -- Trae 的回调不带 state(token 直接放在 query 里),非 Trae 必须 state 匹配。 + local stateOK = (s.platform == "trae") or (gotState ~= "" and gotState == s.nonce) + -- 只有「回调真到了且 state 对」才推进;文件还没出现时保持 pending, + -- 绝不能把「用户还没操作完」当成失败。 + if cb and stateOK then + local plat = platformById(s.platform) + local credential, account, cerr + if s.platform == "lobsterai" then + -- 有道:authCode → exchange(与 oauth_callback 路径共用同一助手,防漂移) + credential, account, cerr = lobsteraiExchange(plat, code, s) + elseif s.platform == "trae" then + -- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo), + -- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。 + credential, account, cerr = traeCallback(plat, cb, s) + if not credential and code ~= "" then + -- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换 + local body = (plat.callback.exchange_body or "") + :gsub("{client_id}", plat.callback.client_id or "") + :gsub("{code}", code) + :gsub("{port}", tostring(gatewayPort())) + local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url, + method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body }) + if err or not resp or resp.status ~= 200 then + cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status)) + else + local ok, rec = pcall(json.decode, resp.body or "") + local access = ok and type(rec) == "table" + and (rec.Token or rec.token or rec.AccessToken or rec.access_token) + if not access then cerr = "Trae 授权码交换响应缺少 Token" + else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end + end + end + else + -- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释) + local body = (plat.callback.token_body or "") + :gsub("{code}", code) + :gsub("{port}", tostring(gatewayPort())) + :gsub("{verifier}", s.device_verifier or "") + local resp, err = __llmsproxy_http({ + url = plat.callback.token_url, method = "POST", + headers = { ["Content-Type"] = "application/x-www-form-urlencoded", + ["Accept"] = "application/json" }, + body = body, + }) + if err or not resp or resp.status ~= 200 then + cerr = "token 交换失败: " .. tostring(err or (resp and resp.status)) + if resp and resp.body then cerr = cerr .. "(" .. tostring(resp.body):sub(1, 160) .. ")" end + else + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or not rec.access_token then + cerr = "token 交换响应缺少 access_token" + else + credential, account = json.encode(rec), "oauth" + end + end + end + reapLoopback(s.id) + if cerr then + s.state = "failed" + s.message = cerr + else + local res = plugin.complete_login({ platform = s.platform, credential = credential, + account = account, account_id = s.account_id }) + if res.json and (res.json.ok or res.json.account_id) then + s.state = "completed" + s.account = account + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end + end + end + end + -- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code** -- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。 -- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。 @@ -2320,7 +3144,7 @@ function plugin.poll_login(req) s.state = "failed" s.message = rerr else - -- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号, + -- 必须带 account_id:页面流程先 account/add 建了占位账号, -- 不带就会落到与占位账号无关的 `channels:` 键(见 poll_login 顶部注释)。 local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account, account_id = s.account_id }) @@ -2548,9 +3372,9 @@ function plugin.poll_login(req) end end -- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token / - -- expires_at / domain / user_id / nickname,而非只存裸 access_token。 - -- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默 - -- 续期,且 expires_at 未知。 + -- expires_at / domain / user_id / nickname,而非只存裸 access_token。 + -- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默 + -- 续期,且 expires_at 未知。 local credTable = buildCredential(data, accountData) local credJson = json.encode(credTable) local acct = credTable.nickname @@ -2559,7 +3383,7 @@ function plugin.poll_login(req) end if acct == nil or acct == "" then acct = "codebuddy" end -- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的 - -- `channels:` 键,账号池查不到。 + -- `channels:` 键,账号池查不到。 local res = plugin.complete_login({ platform = s.platform, credential = credJson, account = acct, account_id = s.account_id, expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) }) @@ -2670,7 +3494,8 @@ function plugin.complete_login(req) max_concurrent = 2, models = (function() local ms = {} - for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end + local eff = effectiveModels(plat.id) + for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), }, @@ -2702,13 +3527,19 @@ function plugin.claim_daily(req) return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } } end -- 账号池路径:claim 某个池账号;否则旧路径单凭据。 - local tok, acctEntry + -- credArgs 要一并交给 claim_fn:Trae 的签到头要用凭据里的 + -- uid/access_token 派生设备身份(仅凭裸 token 拿不到这些字段)。 + local tok, acctEntry, credArgs if args.account_id and args.account_id ~= "" then acctEntry = poolGet(plat.id, args.account_id) if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end + local _, _, rec = credentialOf(acctEntry.credentialRef) tok = credentialOf(acctEntry.credentialRef) + credArgs = { account_id = args.account_id, credential = rec } else + local _, _, rec = credentialOf("channels:" .. plat.id) tok = credentialOf("channels:" .. plat.id) + credArgs = { credential = rec } end if not tok then return { status = 400, json = { error = "未登录" } } end @@ -2722,7 +3553,7 @@ function plugin.claim_daily(req) body = plat.checkin.body or "{}", } if plat.claim_fn then - local custom, cerr = plat.claim_fn(plat, tok, args) + local custom, cerr = plat.claim_fn(plat, tok, credArgs) if cerr then return { status = 400, json = { error = cerr } } end if custom then spec = custom end end @@ -2822,6 +3653,8 @@ plugin.http = { roles = { "admin" }, description = "调账号优先级" }, { path = "channels/model/list", method = "POST", handler = "model_list", roles = { "admin" }, description = "列平台模型与开关态" }, + { path = "channels/model/refresh", method = "POST", handler = "model_refresh", + roles = { "admin" }, description = "从上游拉取模型目录(远端为主、兜底补位)" }, { path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled", roles = { "admin" }, description = "开关单个模型" }, { path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled", @@ -2887,8 +3720,8 @@ plugin.http = { -- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 -- base_url)。两个用途: --- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求) --- 2. 测试把端点指到假上游,行为测试才能不打真厂商 +-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求) +-- 2. 测试把端点指到假上游,行为测试才能不打真厂商 -- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段 -- 会破坏后续逻辑对形状的假设。 -- ============================================================================ @@ -3036,6 +3869,40 @@ end -- ============================================================================ -- 模型开关 RPC(对齐参考 model.*) -- ============================================================================ +-- plugin.model_refresh(platform?):从上游拉模型目录。 +-- +-- 不传 platform 就刷新所有**声明了 models_remote** 的平台。失败不清缓存 +-- (宁可留旧目录也别让平台变空),逐个返回结果供 UI 提示。 +function plugin.model_refresh(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local targets = {} + if args and args.platform then + local plat = platformById(args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + targets = { plat } + else + for _, p in ipairs(allPlatforms()) do + if p.models_remote then targets[#targets+1] = p end + end + end + local results = {} + for _, plat in ipairs(targets) do + local ids, err = refreshRemoteModels(plat.id) + results[#results+1] = { + platform = plat.id, + ok = ids ~= nil, + count = ids and #ids or 0, + error = err, + } + -- 目录变了就重同步该平台所有源(模型清单写进了上游源) + if ids then + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + end + end + return { status = 200, json = { ok = true, results = results } } +end + function plugin.model_list(req) local args = bodyOf(req) if args.platform == nil then args = req end @@ -3043,10 +3910,16 @@ function plugin.model_list(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local disabled = disabledModelsOf(plat.id) local out = {} - for i, m in ipairs(plat.models or {}) do + for i, m in ipairs(effectiveModels(plat.id)) do out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false } end - return { status = 200, json = { ok = true, models = out } } + local remote = plugin.state.remoteModels and plugin.state.remoteModels[plat.id] + return { status = 200, json = { + ok = true, models = out, + remote = remote ~= nil, + fetched_at = remote and remote.fetched_at or nil, + fallbackOnly = remote == nil, + } } end function plugin.model_set_disabled(req) @@ -3070,7 +3943,7 @@ function plugin.model_set_all_disabled(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local disabled = disabledModelsOf(plat.id) if args.disabled then - for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end else for k in pairs(disabled) do disabled[k] = nil end end @@ -3090,13 +3963,14 @@ function plugin.provider_status(req) for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end local disabled = disabledModelsOf(p.id) local disabledN = 0 - for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end - local closed = (p.models and #p.models > 0) and disabledN == #p.models + local eff = effectiveModels(p.id) + for _, m in ipairs(eff) do if disabled[m] then disabledN = disabledN + 1 end end + local closed = #eff > 0 and disabledN == #eff local state_accounts = plugin.state.accounts[p.id] local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0 out[#out+1] = { id = p.id, name = p.name, flow = p.flow, closed = closed, - models = { total = #(p.models or {}), disabled = disabledN }, + models = { total = #eff, disabled = disabledN }, accounts = { total = #accts + legacyN, enabled = enabledN + legacyN }, } end @@ -3122,11 +3996,12 @@ function plugin.provider_set_enabled(req) __llmsproxy_kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", api_key = legacyTok, max_concurrent = 2, - models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), + models = (function() local ms = {} local eff = effectiveModels(plat.id) + for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), } }) end else - for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) end @@ -3419,7 +4294,7 @@ function plugin.model_clear_dead(req) local removed = {} for mid in pairs(disabled) do local alive = false - for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end + for _, m in ipairs(effectiveModels(plat.id)) do if m == mid then alive = true break end end if not alive then disabled[mid] = nil removed[#removed+1] = mid end end markDirty() @@ -3455,9 +4330,9 @@ function plugin.gateway_get_enabled(req) local any = false for _, p in ipairs(allPlatforms()) do local disabled = disabledModelsOf(p.id) - local total = #(p.models or {}) + local total = #effectiveModels(p.id) local allOff = total > 0 - for _, m in ipairs(p.models or {}) do if not disabled[m] then allOff = false break end end + for _, m in ipairs(effectiveModels(p.id)) do if not disabled[m] then allOff = false break end end if not allOff then any = true break end end return { status = 200, json = { ok = true, enabled = any, @@ -3615,6 +4490,36 @@ function plugin.set_endpoints(req) for k in pairs(allowed) do if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end end + if type(args.callback) == "table" and plat.callback then + for _, k in ipairs({ "mode", "token_url" }) do + if args.callback[k] then + plat.callback[k] = tostring(args.callback[k]) + changed[#changed+1] = "callback." .. k + end + end + end + -- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游; + -- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。 + if type(args.client_version) == "table" and plat.client_version then + for _, k in ipairs({ "url", "path", "fallback" }) do + if args.client_version[k] then + plat.client_version[k] = tostring(args.client_version[k]) + changed[#changed+1] = "client_version." .. k + end + end + plugin.state.clientVersion = nil + end + if type(args.models_remote) == "table" and plat.models_remote then + -- 测试台/运维把远端目录端点重定向(键形如 endpoint0/endpoint1 对应该平台的 + -- 第 N 个端点)。生产上不该改,但没有它就无法在无网环境下断言解析口径。 + for i, ep in ipairs(plat.models_remote.endpoints or {}) do + local v = args.models_remote["endpoint" .. (i - 1)] + if v then + ep.url = tostring(v) + changed[#changed+1] = "models_remote.endpoint" .. (i - 1) + end + end + end if type(args.qr) == "table" and plat.qr then for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do if args.qr[k] then @@ -3748,8 +4653,8 @@ end -- -- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能 -- 变成上游源。取法分两种: --- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象) --- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量 +-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象) +-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量 -- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。 -- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。 function plugin.collect_credentials(req) @@ -3886,9 +4791,9 @@ end -- 这个网关的登录流程。 -- -- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据: --- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。 --- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现 --- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。 +-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。 +-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现 +-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。 function plugin.oauth_callback(req) local nonce = req.query and req.query.state if not nonce or nonce == "" then @@ -3905,13 +4810,19 @@ function plugin.oauth_callback(req) if s.flow ~= "callback" then return { status = 400, body = "session is not a callback flow" } end + if s.state ~= "pending" then + return { status = 409, body = "login session already consumed" } + end local plat = platformById(s.platform) local cb = plat and plat.callback or {} local mode = cb.mode or "code" local credential, account, cerr if mode == "trae" then - credential, account, cerr = traeCallback(plat, req.query) + credential, account, cerr = traeCallback(plat, req.query, s) + elseif mode == "code" and s.platform == "lobsterai" then + -- 与 poll_login 的 loopback 路径共用同一个交换助手,避免两条路漂移。 + credential, account, cerr = lobsteraiExchange(plat, req.query.authCode or req.query.code, s) elseif mode == "code" and cb.token_url then -- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。 local code = req.query.code @@ -3921,10 +4832,24 @@ function plugin.oauth_callback(req) local body = (cb.token_body or "") :gsub("{code}", code) :gsub("{verifier}", s.device_verifier or "") + -- {now}/{uuid}:LobsterAI 的交换体要求这两个字段(照参考 + -- exchangeLobsteraiAuthCode:latestKeyfrom 取当前时刻、uuid 来自会话)。 + :gsub("{now}", tostring(os.time and os.time() * 1000 or 0)) + :gsub("{uuid}", s.nonce or "") + -- {port}:Gemini 的 redirect_uri 参与 token 交换校验,必须与授权时一致 + :gsub("{port}", tostring(gatewayPort())) + -- Content-Type 按请求体形态选:LobsterAI 发 JSON,Gemini 发表单。 + local ctype = "application/x-www-form-urlencoded" + if body:sub(1, 1) == "{" then ctype = "application/json" end + local hdrs = { ["Content-Type"] = ctype, ["Accept"] = "application/json" } + -- 平台的匿名头(LobsterAI 的交换端点虽不需 Authorization,但要 UA) + if type(plat.callback_headers) == "table" then + for k, v in pairs(plat.callback_headers) do hdrs[k] = v end + end local resp, err = __llmsproxy_http({ url = cb.token_url, method = "POST", - headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" }, + headers = hdrs, body = body, }) if err then @@ -3967,14 +4892,20 @@ function plugin.oauth_callback(req) end -- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。 --- 返回 (credential, account, nil) 或 (nil, nil, err)。 +-- 返回 (credential, account, err)。 +-- +-- credential 是 **JSON**(不是裸 access token):device_id 必须随凭据保存, +-- 因为签到要发 `X-Device-Id`,且该值限定为「每账号互不相同」(同一天两账号 +-- 共用会被「该设备已签到」拦,业务码 9074 的限流范围是 device_id 而非账号)。 +-- 参考 TraeCredential 即含 deviceId/machineId。凭据的取用端 credentialOf 会 +-- 从中取 access_token,故包成 JSON 不影响上游源。 -- -- 行为逐行对齐参考实现 trae-oauth.ts: --- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新 --- access(并轮换 refreshToken);凭据 = 新 access。 --- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。 --- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。 --- 都没有 → 回调无效。 +-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新 +-- access(并轮换 refreshToken);凭据 = 新 access。 +-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。 +-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。 +-- 都没有 → 回调无效。 local function jsonField(raw, key) if not raw or raw == "" then return "" end local ok, v = pcall(json.decode, raw) @@ -3982,7 +4913,7 @@ local function jsonField(raw, key) return v[key] or "" end -function traeCallback(plat, q) +traeCallback = function(plat, q, s) q = q or {} local userJwt = q.userJwt or "" local refreshToken = q.refreshToken @@ -3990,7 +4921,13 @@ function traeCallback(plat, q) refreshToken = jsonField(userJwt, "RefreshToken") end local jwtToken = jsonField(userJwt, "Token") - local account = jsonField(q.userInfo or "", "ScreenName") + -- uid 与昵称都必须从 userInfo 取:uid 是签到设备身份的**派生种子** + -- (参考 trae-oauth.ts:回调字段名是 UserID,不是 user_id); + -- 昵称需经乱码修复(中文实测会变成 Óû§8847309959 这类形态)。 + local userInfo = q.userInfo or "" + local uid = jsonField(userInfo, "UserID") + local enterpriseId = jsonField(userInfo, "TenantID") + local account = traeFixNickname(jsonField(userInfo, "ScreenName"), uid) if account == "" then account = "trae" end -- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。 @@ -4003,6 +4940,18 @@ function traeCallback(plat, q) return nil, nil, "回调未携带 refreshToken / userJwt.Token / code" end + -- 凭据包成 JSON:device_id/machine_id 必须留存供签到用(见函数注释)。 + local function packTrae(access) + local cred = { access_token = access, uid = uid, enterprise_id = enterpriseId, + nickname = account } + if s then + cred.device_id = s.device_id + cred.machine_id = s.machine_id + cred.login_trace_id = s.login_trace_id + end + return json.encode(cred) + end + -- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh) if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then local body = (plat.callback.exchange_body @@ -4026,17 +4975,222 @@ function traeCallback(plat, q) if not access or access == "" then return nil, nil, "TRAE ExchangeToken 响应缺少 Token" end - return access, account, nil + return packTrae(access), account, nil end -- 分支 2:无 refreshToken,用 userJwt.Token 兜底 - return jwtToken, account, nil + if jwtToken and jwtToken ~= "" then return packTrae(jwtToken), account, nil end + return nil, nil, "回调未携带可用的 token" +end + +-- traeDeriveStream(seed, salt, nbytes):SHA-256 确定性伪随机流(返回 hex 串)。 +-- +-- 逐行对齐参考 seededStream:`SHA256(utf8(salt:seed) ++ counterBE32)` 串联, +-- counter 从 0 递增,取前 nbytes 字节。同样的 (seed,salt) 永远得到同样的字节。 +-- 内核只给 sha256_hex(字符串),没有二进制原语,故自己拼大端 counter: +-- 把计数器按 4 字节大端拆成字符,先 tohex 再转义成逐字节的字面量。 +local function traeDeriveStream(seed, salt, nbytes) + local prefix = tostring(salt) .. ":" .. tostring(seed) + local out, counter = "", 0 + while #out < nbytes * 2 do + -- counter 是大端 4 字节;counter=0 时就是 4 个 NUL。 + -- 必须用 string.char 造**真字节**:内核的 sha256_hex 经 GoStringN 按长度取串, + -- NUL 会原样保留。曾经试过用 `\\ddd` 转义串,那是错的 —— Lua 不会对 + -- 运行时拼接的字符串重新词法分析,于是哈希到的是「反斜杠+数字」文本。 + local be = string.char( + math.floor(counter / 16777216) % 256, math.floor(counter / 65536) % 256, + math.floor(counter / 256) % 256, counter % 256) + out = out .. sha256_hex(prefix .. be) + counter = counter + 1 + end + return out:sub(1, nbytes * 2) +end + +-- traeDeriveDeviceId15 / traeDeriveMarketUserId / traeDeriveSessionId: +-- 基于 user_id 的确定性设备身份。参考注释点明:每个账号天然不同设备, +-- 所以签到**不再需要**在遇到 9074 时换设备号重试(早期做法已废弃)。 +traeDeriveDeviceId15 = function(userId) + local hex = traeDeriveStream(userId, "devid", 15) + local out = {} + for i = 1, 15 do + -- 按字节取模 10:两 hex 字符 = 1 字节 + local b = tonumber(hex:sub((i - 1) * 2 + 1, i * 2), 16) + out[#out + 1] = tostring(b % 10) + end + return table.concat(out) +end + +traeDeriveMarketUserId = function(userId) + local h = traeDeriveStream(userId, "market", 16) + local bs = {} + for i = 1, 16 do bs[i] = tonumber(h:sub((i - 1) * 2 + 1, i * 2), 16) end + bs[7] = (bs[7] % 16) + 64 -- version 4(bs 下标从 1 起,故是第 7 个) + bs[9] = (bs[9] % 64) + 128 -- variant RFC 4122 + local hx = {} + for i = 1, 16 do hx[i] = ("%02x"):format(bs[i]) end + local s = table.concat(hx) + return s:sub(1, 8) .. "-" .. s:sub(9, 12) .. "-" .. s:sub(13, 16) + .. "-" .. s:sub(17, 20) .. "-" .. s:sub(21, 32) +end + +traeDeriveSessionId = function(userId) + return traeDeriveStream(userId, "sess", 32) +end + +-- utf8Encode(cp):码点 → UTF-8 字节串(Lua 5.1 无 utf8.char)。 +local function utf8Encode(cp) + if cp < 0x80 then return string.char(cp) end + if cp < 0x800 then + return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64) + end + if cp < 0x10000 then + return string.char(0xE0 + math.floor(cp / 4096), + 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64) + end + return string.char(0xF0 + math.floor(cp / 262144), + 0x80 + math.floor(cp / 4096) % 64, 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64) +end + +-- latin1ToUtf8(s):把「每字符=一原始字节」的串重按 UTF-8 解一次。 +-- +-- 这是参考 fixNicknameMojibake 里 `Buffer.from(raw,'latin1').toString('utf8')` +-- 的等价物:TRAE 回调的中文被错按 latin-1 解了一次,实测昵称变成 +-- `Óû§8847309959` 这类乱码。返回 (修复串, true) 或 (nil, false)。 +local function latin1ToUtf8(s) + local out, i, n = {}, 1, #s + while i <= n do + local b = string.byte(s, i) + if not b then break end + local cp, len + if b < 0x80 then cp, len = b, 1 + elseif b >= 0xC2 and b <= 0xDF then cp, len = b - 0xC0, 2 + elseif b >= 0xE0 and b <= 0xEF then cp, len = b - 0xE0, 3 + elseif b >= 0xF0 and b <= 0xF4 then cp, len = b - 0xF0, 4 + else return nil, false end + if i + len - 1 > n then return nil, false end + for k = 1, len - 1 do + local cb = string.byte(s, i + k) + if not cb or cb < 0x80 or cb > 0xBF then return nil, false end + cp = cp * 64 + (cb - 0x80) + end + -- 拒绝非最短编码、代理区与越界(否则会把垃圾字节当合法字符) + if (len == 2 and cp < 0x80) or (len == 3 and cp < 0x800) + or (len == 4 and cp < 0x10000) or cp > 0x10FFFF + or (cp >= 0xD800 and cp <= 0xDFFF) then + return nil, false + end + out[#out + 1] = utf8Encode(cp) + i = i + len + end + return table.concat(out), true +end + +-- hasCJK(s):是否含 CJK 汉字(UTF-8 三字节,U+4E00–U+9FFF)。 +-- 照参考 `/\u4e00-\u9fff/`:无 CJK 则判乱码,回退可读占位。 +local function hasCJK(s) + if type(s) ~= "string" then return false end + local i = 1 + while i <= #s do + local b = string.byte(s, i) + if b and b >= 0xE4 and b <= 0xE9 then + local b2, b3 = string.byte(s, i + 1), string.byte(s, i + 2) + if b2 and b3 and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then + local cp = (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80) + if cp >= 0x4E00 and cp <= 0x9FFF then return true end + end + end + i = i + 1 + end + return false +end + +-- traeFixNickname(raw, uid):照参考 fixNicknameMojibake 的两级处理。 +-- ① 先试 latin1→utf8 回转(结果含 CJK 才采用); +-- ② 不行且无 CJK → 「用户+uid末4位」占位(绝不把乱码写进凭据)。 +traeFixNickname = function(raw, uid) + if raw == nil or raw == "" then return raw or "" end + local fixed, ok = latin1ToUtf8(raw) + if ok and fixed ~= "" and hasCJK(fixed) then return fixed end + if not hasCJK(raw) then + return "\231\148\168\230\136\183" .. tostring(uid):sub(-4) + end + return raw +end + +-- traeCheckinHeaders(plat, cred, uid):签到/积分请求头。 +-- +-- 参考注释点明:简化头(仅 Authorization + 少量 Ug 头)**实测签到失败**; +-- 真正成功的是约 20 个客户端头。关键差别:X-Device-Id 用的是 +-- **基于 user_id 确定性派生的 15 位数字**(不是凭据里的 hex32)。 +-- 授权方案是 `Cloud-IDE-JWT`(不是 Bearer)—— 用错前缀是 401 级失败。 +-- 依赖:plat.checkin.auth 声明方案(trae 声明 cloud-ide-jwt)。 +traeCheckinHeaders = function(plat, cred, uid) + uid = uid or cred.uid or "" + if uid == "" then return nil, "缺少 user_id,无法构造设备身份" end + local h = { + ["Authorization"] = "Cloud-IDE-JWT " .. tostring(cred.access_token or ""), + ["Content-Type"] = "application/json", + ["Accept"] = "*/*", + ["Accept-Language"] = "zh-CN", + ["User-Agent"] = "VSCode 1.107.1 (TRAE SOLO CN)", + ["X-Market-Client-Id"] = "VSCode 1.107.1", + ["X-Market-User-Id"] = traeDeriveMarketUserId(uid), + ["X-User-Region"] = "CN", + ["X-Device-Id"] = traeDeriveDeviceId15(uid), + ["X-Lgw-Req-Sdk-Type"] = "3", + ["Package-Type"] = "stable_cn", + ["X-Lscbd-Aid"] = "787976", + ["X-Lscbd-Platform"] = "windows", + ["App-Version"] = plat.ide_version or "0.1.52", + ["Vscode-Sessionid"] = traeDeriveSessionId(uid), + ["Sec-Fetch-Dest"] = "empty", + } + -- 每次请求独立的 trace/request id(形如 00--01 / uuid v4) + h["X-Tt-Trace-Id"] = "00-" .. newToken():sub(1, 16) .. "-01" + local r = newToken() + h["X-Request-Id"] = r:sub(1, 8) .. "-" .. r:sub(9, 12) .. "-4" .. r:sub(14, 16) + .. "-8" .. r:sub(18, 20) .. "-" .. r:sub(21, 32) + return h, nil +end + +-- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。 +-- +-- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。 +do + for _, p in ipairs(PLATFORMS) do + if p.checkin and p.checkin.auth == "cloud-ide-jwt" then + p.claim_fn = function(plat, tok, args) + local cred = (args and args.credential) or { access_token = tok } + local uid = (args and args.uid) or cred.uid + or (type(cred.user_id) == "string" and cred.user_id) or "" + local h, herr = traeCheckinHeaders(plat, cred, uid) + if herr then return nil, herr end + return { url = plat.checkin.url, method = plat.checkin.method or "POST", + headers = h, body = plat.checkin.body or "{}" } + end + end + end +end + +-- plugin.__trae_derive_probe 只给测试做差分用(拿参考实现生成的期望值比对)。 +-- 不挂 HTTP 路由、不暴露给 UI;它必须在派生函数**定义之后**才存在, +-- 因为它们都是 local,插件的 plugin 表又建得早。 +function plugin.__trae_derive_probe(args) + local uid = tostring((type(args) == "table" and args.uid) or "") + return { json = { devid = traeDeriveDeviceId15(uid), + market = traeDeriveMarketUserId(uid), + sess = traeDeriveSessionId(uid) } } end -- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的, -- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。 plugin.on_unload = function() reapBrowser() + -- 回调接收器也是本插件起的进程,卸载/重载时一并回收。 + -- 不能只收浏览器栈:否则每次插件重载都留下一批 python 监听。 + for id in pairs(plugin.state.loopbacks or {}) do + reapLoopback(id) + end end -- ============================================================================ @@ -4047,7 +5201,7 @@ plugin.ui = { page = { page_id = "channels", title = "账号接入", - icon = "🔌", + icon = "", order = 40, mount = [[
@@ -4089,6 +5243,8 @@ plugin.ui = { + +
@@ -4224,12 +5380,32 @@ plugin.ui = { }).join(""); } + window.chModelRefresh = async () => { + const id = state.sel && state.sel.id; + if (!id) return; + const r = await call("channels/model/refresh", "POST", { platform: id }); + const res = (r.results || [])[0] || {}; + if (res.ok) alert("已从上游拉取 " + res.count + " 个模型"); + else alert("拉取失败:" + (res.error || "未知错误") + "(保留既有目录)"); + await renderModels(id); + }; + async function renderModels(id) { const j = await call("channels/model/list", "POST", { platform: id }); const ms = j.models || []; state.models = ms; const on = ms.filter((m) => !m.disabled).length; q("#ch-model-count").textContent = on + "/" + ms.length + " 启用"; + // 目录来源:远端拉到的目录以远端为准、兜底补位;没拉过则只有兜底。 + const src = q("#ch-model-src"); + if (src) { + if (j.remote) { + const t = j.fetched_at ? new Date(j.fetched_at * 1000).toLocaleString() : ""; + src.textContent = "上游目录(" + t + ")"; + } else { + src.textContent = "内置兜底目录(未从上游拉取)"; + } + } q("#ch-models").innerHTML = ms.map((m) => '
' + '' + esc(m.name || m.id) + '' + diff --git a/tools/trae_derive_expected.mjs b/tools/trae_derive_expected.mjs new file mode 100644 index 0000000..7f6108d --- /dev/null +++ b/tools/trae_derive_expected.mjs @@ -0,0 +1,74 @@ +// 生成 Trae 设备身份派生的差分期望值(供 internal/core/channels_trae_derive_test.go 比对)。 +// +// 为什么要有这个文件:channels.lua 里的 traeDeriveStream 是**手工移植**的 +// SHA-256 确定性伪随机流(Lua 5.1 没有 node:crypto)。移植错了不会报错 —— +// 上游只会返回风控/失败,看起来像"运气不好"。所以拿参考实现(参考仓库 +// vendor/channel-pack/src/trae.ts 的 seededStream/seededDigits)生成期望值, +// 再让 Lua 侧算同一组输入做差分。 +// +// 用法(改了参考或改了移植后都要重跑): +// node tools/trae_derive_expected.mjs > internal/core/trae_derive_expected.json +// +// 注意:这里刻意**逐行照抄**参考的 seededStream,而不是从参考仓库 import —— +// 参考是 TS/ESM 且依赖其构建链,直接抄这 20 行更能保证"同一算法"。 + +import { createHash } from 'node:crypto' + +/** 照抄 trae.ts:seededStream —— SHA256(utf8(salt:seed) ++ counterBE32) 串联。 */ +function seededStream(seed, salt, nbytes) { + const prefix = `${salt}:${seed}` + const result = [] + let counter = 0 + while (result.length < nbytes) { + const counterBuf = new Uint8Array(4) + counterBuf[0] = (counter >> 24) & 0xff + counterBuf[1] = (counter >> 16) & 0xff + counterBuf[2] = (counter >> 8) & 0xff + counterBuf[3] = counter & 0xff + const h = createHash('sha256') + h.update(prefix, 'utf8') + h.update(counterBuf) + for (const b of h.digest()) { + result.push(b) + if (result.length >= nbytes) break + } + counter++ + } + return result.slice(0, nbytes) +} + +/** 照抄 trae.ts:seededDigits —— 每字节 % 10,拼成 n 位数字串。 */ +function seededDigits(n, seed, salt) { + return seededStream(seed, salt, n).map((b) => b % 10).join('') +} + +/** 照抄 trae.ts:deriveDeviceId15。 */ +function deriveDeviceId15(userId) { + return seededDigits(15, userId, 'devid') +} + +/** 照抄 trae.ts:deriveMarketUserId —— UUID v4 形状。 */ +function deriveMarketUserId(userId) { + const bs = seededStream(userId, 'market', 16) + bs[6] = (bs[6] & 0x0f) | 0x40 + bs[8] = (bs[8] & 0x3f) | 0x80 + const hex = bs.map((b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + +/** 照抄 trae.ts:deriveSessionId —— 64 位 hex。 */ +function deriveSessionId(userId) { + return seededStream(userId, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join('') +} + +// 覆盖边界:纯数字 / 带连字符 / 空串(空串也要能算,不能崩)/ 多字节 UTF-8 / +// 超长(超过一个 SHA-256 块,逼出 counter 递增分支 —— 移植时最易错的地方)。 +const ids = ['12345', 'UID-abc', '900000001', 'x', '', '用户1', '1234567890123456789012345'] + +const out = ids.map((u) => ({ + u, + devid: deriveDeviceId15(u), + market: deriveMarketUserId(u), + sess: deriveSessionId(u), +})) +process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)