From 4db035682a1b7b26f108a8d0f051f3288b7a9658 Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 02:10:21 +0800 Subject: [PATCH] =?UTF-8?q?zcode:=20a=20Z.ai=20and=20a=20BigModel=20(?= =?UTF-8?q?=E6=99=BA=E8=B0=B1)=20account=20on=20the=20same=20phone=20numbe?= =?UTF-8?q?r,=20or=20two=20accounts=20ZCode=20names=20alike,=20are=20kept?= =?UTF-8?q?=20apart=20(Bandit=20on=20Discord:=20authorising=20one=20replac?= =?UTF-8?q?ed=20the=20other)=20=E2=80=94=20magpie=20kept=20ZCode's=20accou?= =?UTF-8?q?nts=20by=20name=20alone,=20the=20name=20being=20the=20email,=20?= =?UTF-8?q?or=20the=20phone=20number=20of=20a=20phone=20sign-in=20(?= =?UTF-8?q?@phone.local),=20so=20a=20sign-in=20whose=20name=20an=20account?= =?UTF-8?q?=20already=20had=20overwrote=20that=20account's=20key,=20on=20w?= =?UTF-8?q?hichever=20site;=20one=20named=20as=20ZCode's=20own=20account?= =?UTF-8?q?=20was=20taken=20for=20it=20and=20not=20kept=20at=20all.=20A=20?= =?UTF-8?q?sign-in=20now=20keeps=20the=20account's=20user=20id=20beside=20?= =?UTF-8?q?its=20key,=20and=20an=20account=20is=20the=20same=20only=20on?= =?UTF-8?q?=20the=20same=20site=20with=20the=20same=20id=20(one=20kept=20b?= =?UTF-8?q?efore=20ids,=20by=20its=20key's=20id);=20another=20account=20wi?= =?UTF-8?q?th=20the=20name=20is=20kept=20as=20'=20(BigModel)'=20or?= =?UTF-8?q?=20'=20(Z.ai)',=20numbered=20when=20that=20is=20taken,=20?= =?UTF-8?q?and=20signing=20in=20to=20an=20account=20again=20still=20update?= =?UTF-8?q?s=20it=20in=20place,=20by=20id.=20There=20is=20no=20cap=20on=20?= =?UTF-8?q?the=20number=20of=20accounts.=20TestZCodeAccountsKeptApart,=20T?= =?UTF-8?q?estZCodeOldAccountKeptByKey=20and=20TestZCodeOwnAccountKeptApar?= =?UTF-8?q?t=20fail=20without=20the=20change.=20go=20vet,=20go=20build=20-?= =?UTF-8?q?tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go?= =?UTF-8?q?=20build,=20go=20test=20-tags=20nogui=20./...=20pass=20(TestUse?= =?UTF-8?q?rPath,=20a=20known=20load=20flake,=20passes=20alone).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/zcode.go | 67 ++++++- internal/provider/zcode_accounts_test.go | 236 +++++++++++++++++++++++ 2 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 internal/provider/zcode_accounts_test.go diff --git a/internal/provider/zcode.go b/internal/provider/zcode.go index dde8fb3b..1a2b070e 100644 --- a/internal/provider/zcode.go +++ b/internal/provider/zcode.go @@ -39,6 +39,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" "time" @@ -77,6 +78,9 @@ type zcodeKey struct { Token string `json:"token,omitempty"` Org string `json:"org,omitempty"` Project string `json:"project,omitempty"` + // UID is the account's user id, as ZCode's sign-in names it: with the + // site, which account this is (zcodeSame). + UID string `json:"uid,omitempty"` } // ---- ZCode's own account ------------------------------------------------------ @@ -192,6 +196,7 @@ func zcodeOwn() (who string, k zcodeKey, ok bool) { } } } + k.UID = strings.TrimSpace(info.ID) return zcodeWho(info.Email, info.Name, info.ID), k, true } @@ -622,6 +627,57 @@ func zcodeGet(ctx context.Context, u, key string, dst any) error { return zcodeCall(ctx, http.MethodGet, u, key, nil, dst) } +// zcodeSite is the site a key's account is on: "bigmodel" or "zai". +func zcodeSite(k zcodeKey) string { + if k.Base == ZCodeBigModelBase || strings.HasSuffix(hostOf(k.Base), "bigmodel.cn") { + return "bigmodel" + } + return "zai" +} + +// zcodeSame says two keys are the same account: on the same site, with the +// same user id; one kept before magpie kept ids is told by its key's id, +// and taken for the same when it has none, as it was before. +func zcodeSame(a, b zcodeKey) bool { + if zcodeSite(a) != zcodeSite(b) { + return false + } + if a.UID != "" && b.UID != "" { + return a.UID == b.UID + } + ia, _, _ := strings.Cut(a.Key, ".") + ib, _, _ := strings.Cut(b.Key, ".") + return ia == "" || ib == "" || ia == ib +} + +// zcodeName is the name an account signed in is kept under: who, as ZCode +// names it, unless another account is kept under that name, then who and +// its site, numbered when that is taken too. Accounts are kept by name, so +// a Z.ai and a BigModel account on the same phone number, or two ZCode +// names alike, took each other's place (Bandit on Discord). The same +// account signed in again keeps its name and is updated in place. +func zcodeName(who, site string, k zcodeKey, have []zcodeLoginKey) string { + if k.UID != "" { + for _, l := range have { + if l.key.UID == k.UID && zcodeSite(l.key) == site { + return l.User + } + } + } + for n := 1; ; n++ { + name := who + if n == 2 { + name = who + " (" + zcodeSiteName(site) + ")" + } else if n > 2 { + name = fmt.Sprintf("%s (%s %d)", who, zcodeSiteName(site), n-1) + } + i := slices.IndexFunc(have, func(l zcodeLoginKey) bool { return strings.EqualFold(l.User, name) }) + if i < 0 || zcodeSame(have[i].key, k) { + return name + } + } +} + // ---- signing in --------------------------------------------------------------- // startZCodeSignIn is ZCode's own polling sign-in: zcode.z.ai opens a flow, @@ -723,17 +779,22 @@ func startZCodeSignIn(s *signInFlow, site string) error { fail("ZCode sign-in: unexpected answer " + got.Status) return } - who := zcodeWho(got.User.Email, got.User.Name, got.User.ID) k, plan, err := zcodeSignedIn(ctx, site, token, strings.TrimSpace(got.Token)) if err != nil { fail(err.Error()) return } - auth, _ := json.Marshal(k) - ownUser, _, ok := zcodeOwn() + k.UID = strings.TrimSpace(got.User.ID) + ownUser, own, ok := zcodeOwn() if !ok { ownUser = "" } + have := zcodeLogins() + if ok && !slices.ContainsFunc(have, func(l zcodeLoginKey) bool { return strings.EqualFold(l.User, ownUser) }) { + have = append(have, zcodeLoginKey{Login{User: ownUser}, own}) // removed in magpie, still ZCode's + } + who := zcodeName(zcodeWho(got.User.Email, got.User.Name, got.User.ID), site, k, have) + auth, _ := json.Marshal(k) if err := addSideLogin(savedLogin{Agent: "zcode", User: who, Plan: plan, Auth: auth}, ownUser, func(savedLogin) {}); err != nil { fail(err.Error()) return diff --git a/internal/provider/zcode_accounts_test.go b/internal/provider/zcode_accounts_test.go new file mode 100644 index 00000000..0ad8c70d --- /dev/null +++ b/internal/provider/zcode_accounts_test.go @@ -0,0 +1,236 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// zcodeFakeAccount is an account on Z.ai or BigModel as ZCode's sign-in +// hands it back. +type zcodeFakeAccount struct { + site, uid, email, name string + secret string // its zcode-api-key's secret +} + +// newZCodeAccountsUpstream is zcode.z.ai, Z.ai and BigModel for these +// tests: each sign-in's poll answers with next's account, whose own +// project holds its zcode-api-key, k-., on a GLM Coding Plan. +func newZCodeAccountsUpstream(t *testing.T) (next func(zcodeFakeAccount)) { + t.Helper() + var ( + mu sync.Mutex + flows = map[string]zcodeFakeAccount{} + queue []zcodeFakeAccount + byTok = map[string]zcodeFakeAccount{} // business Authorization → account + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + auth := r.Header.Get("Authorization") + ok := func(data any) { json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": data}) } + p := r.URL.Path + switch { + case p == "/api/v1/oauth/cli/init": + if len(queue) == 0 { + w.WriteHeader(400) + return + } + id := fmt.Sprintf("f%d", len(flows)+1) + flows[id], queue = queue[0], queue[1:] + ok(map[string]any{"flow_id": id, "authorize_url": "https://chat.z.ai/oauth?client_id=c", + "expires_at": time.Now().Add(time.Minute).Unix(), "poll_interval_sec": 0}) + case strings.HasPrefix(p, "/api/v1/oauth/cli/poll/"): + a := flows[strings.TrimPrefix(p, "/api/v1/oauth/cli/poll/")] + tok := "tok-" + a.uid + got := map[string]any{"status": "ready", "token": "zc-" + a.uid, + "user": map[string]any{"user_id": a.uid, "email": a.email, "name": a.name}} + if a.site == "bigmodel" { + got["bigmodel"] = map[string]any{"accessToken": tok} + byTok[tok] = a + } else { + got["zai"] = map[string]any{"access_token": tok} + byTok["Bearer biz-"+a.uid] = a + } + ok(got) + case p == "/api/auth/z/login": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + uid := strings.TrimPrefix(body["token"], "tok-") + ok(map[string]any{"access_token": "biz-" + uid}) + case p == "/api/biz/customer/getCustomerInfo": + a, found := byTok[auth] + if !found { + w.WriteHeader(401) + return + } + ok(map[string]any{"organizations": []any{map[string]any{"organizationId": "o-" + a.uid, "organizationName": "默认机构", + "projects": []any{map[string]any{"projectId": "p-" + a.uid, "projectName": "默认项目", "projectType": "1"}}}}}) + case strings.HasSuffix(p, "/api_keys"): + a, found := byTok[auth] + if !found || p != "/api/biz/v1/organization/o-"+a.uid+"/projects/p-"+a.uid+"/api_keys" { + w.WriteHeader(401) + return + } + ok([]any{map[string]any{"name": "zcode-api-key", "apiKey": "k-" + a.uid}}) + case strings.Contains(p, "/api_keys/copy/"): + a, found := byTok[auth] + if !found || !strings.HasSuffix(p, "/copy/k-"+a.uid) { + w.WriteHeader(401) + return + } + ok(map[string]any{"secretKey": a.secret}) + case p == "/api/biz/subscription/list": + ok([]any{map[string]any{"productName": "GLM Coding Pro", "status": "VALID"}}) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + oldAPI, oldZai, oldBM, oldZaiBase, oldBMBase := zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI, ZCodeZaiBase, ZCodeBigModelBase + zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI = srv.URL, srv.URL, srv.URL + ZCodeZaiBase, ZCodeBigModelBase = srv.URL+"/zai/api/anthropic", srv.URL+"/bigmodel/api/anthropic" + t.Cleanup(func() { + zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI, ZCodeZaiBase, ZCodeBigModelBase = oldAPI, oldZai, oldBM, oldZaiBase, oldBMBase + }) + return func(a zcodeFakeAccount) { + mu.Lock() + defer mu.Unlock() + queue = append(queue, a) + } +} + +func zcodeSignInAs(t *testing.T, next func(zcodeFakeAccount), a zcodeFakeAccount) SignInState { + t.Helper() + next(a) + agent := "zcode" + if a.site == "bigmodel" { + agent = "zcode:bigmodel" + } + st, err := StartSignIn(agent) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + st, _ = WaitSignIn(ctx, st.ID) + if st.State != "done" { + t.Fatalf("sign-in %s: %+v", a.uid, st) + } + return st +} + +// zcodeKeysByUser is every ZCode account magpie keeps, by name: its key. +func zcodeKeysByUser() map[string]string { + out := map[string]string{} + for _, l := range zcodeLogins() { + out[l.User] = l.key.Key + } + return out +} + +// Bandit on Discord: a Z.ai and a BigModel account on the same phone +// number, and then another Z.ai account, each took the place of the one +// added before, as magpie kept ZCode's accounts by name alone. Each is kept +// on its own now; signing in to one again updates it in place. +func TestZCodeAccountsKeptApart(t *testing.T) { + signIn(t) + t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret") + next := newZCodeAccountsUpstream(t) + + const phone = "13800000000" + zai := zcodeFakeAccount{site: "zai", uid: "za1", email: phone + "@phone.local", secret: "s1"} + bm := zcodeFakeAccount{site: "bigmodel", uid: "bm1", email: phone + "@phone.local", secret: "s2"} + zai2 := zcodeFakeAccount{site: "zai", uid: "za2", email: phone + "@phone.local", secret: "s3"} // named alike by ZCode + + if st := zcodeSignInAs(t, next, zai); st.User != phone { + t.Fatalf("z.ai: %+v", st) + } + if st := zcodeSignInAs(t, next, bm); st.User != phone+" (BigModel)" { + t.Fatalf("bigmodel: %+v", st) + } + if st := zcodeSignInAs(t, next, zai2); st.User != phone+" (Z.ai)" { + t.Fatalf("second z.ai: %+v", st) + } + want := map[string]string{phone: "k-za1.s1", phone + " (BigModel)": "k-bm1.s2", phone + " (Z.ai)": "k-za2.s3"} + if got := zcodeKeysByUser(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("accounts: %v, want %v", got, want) + } + for _, l := range zcodeLogins() { + if (l.User == phone+" (BigModel)") != (l.key.Base == ZCodeBigModelBase) { + t.Fatalf("%s served at %s", l.User, l.key.Base) + } + } + + // each signed in again: updated in place under its own name + zai.secret, bm.secret, zai2.secret = "s1b", "s2b", "s3b" + for _, a := range []zcodeFakeAccount{bm, zai2, zai} { + zcodeSignInAs(t, next, a) + } + want = map[string]string{phone: "k-za1.s1b", phone + " (BigModel)": "k-bm1.s2b", phone + " (Z.ai)": "k-za2.s3b"} + if got := zcodeKeysByUser(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("after signing in again: %v, want %v", got, want) + } +} + +// An account kept before magpie kept ids is told by its key: another +// account named alike is kept beside it, and it, signed in again, is +// updated in place and given its id. +func TestZCodeOldAccountKeptByKey(t *testing.T) { + signIn(t) + t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret") + next := newZCodeAccountsUpstream(t) + auth, _ := json.Marshal(zcodeKey{Key: "k-za1.old", Base: ZCodeZaiBase}) + if err := addSideLogin(savedLogin{Agent: "zcode", User: "bandit@example.com", Plan: "GLM Coding Pro", Auth: auth}, "", func(savedLogin) {}); err != nil { + t.Fatal(err) + } + + if st := zcodeSignInAs(t, next, zcodeFakeAccount{site: "zai", uid: "za9", email: "bandit@example.com", secret: "s9"}); st.User != "bandit@example.com (Z.ai)" { + t.Fatalf("another account: %+v", st) + } + if st := zcodeSignInAs(t, next, zcodeFakeAccount{site: "zai", uid: "za1", email: "bandit@example.com", secret: "new"}); st.User != "bandit@example.com" { + t.Fatalf("the old one again: %+v", st) + } + want := map[string]string{"bandit@example.com": "k-za1.new", "bandit@example.com (Z.ai)": "k-za9.s9"} + if got := zcodeKeysByUser(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("accounts: %v, want %v", got, want) + } + for _, l := range zcodeLogins() { + if l.User == "bandit@example.com" && l.key.UID != "za1" { + t.Fatalf("old account's id: %+v", l.key) + } + } +} + +// ZCode's own account, on Z.ai by a phone number: the BigModel account on +// that number, signed in from magpie, was taken for it and never kept. +func TestZCodeOwnAccountKeptApart(t *testing.T) { + home := signIn(t) + t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret") + next := newZCodeAccountsUpstream(t) + writeFile(t, filepath.Join(home, ".zcode", "v2", "credentials.json"), map[string]any{ + "oauth:zai:user_info": zcodeEncrypt(t, `{"user_id":"za1","email":"13800000000@phone.local"}`), + "account-provider:coding-plan:account:zai-individual-coding-plan:account:abc:api-key": zcodeEncrypt(t, "k-za1.own"), + }) + if st := zcodeSignInAs(t, next, zcodeFakeAccount{site: "bigmodel", uid: "bm1", email: "13800000000@phone.local", secret: "s2"}); st.User != "13800000000 (BigModel)" || st.Using { + t.Fatalf("bigmodel: %+v", st) + } + want := map[string]string{"13800000000": "k-za1.own", "13800000000 (BigModel)": "k-bm1.s2"} + if got := zcodeKeysByUser(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("accounts: %v, want %v", got, want) + } + // ZCode's own, signed in from magpie too: it, not another + if st := zcodeSignInAs(t, next, zcodeFakeAccount{site: "zai", uid: "za1", email: "13800000000@phone.local", secret: "own"}); st.User != "13800000000" || !st.Using { + t.Fatalf("own again: %+v", st) + } + if got := zcodeKeysByUser(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("accounts after: %v, want %v", got, want) + } +}