Files
magpie/internal/provider/zcode_team_test.go
T
yetone cb164c1164 providers: a Zhipu or Z.ai key on a team's GLM Coding Plan can be given the team's organization and project IDs, and the Usage page then shows the team's 5-hour and weekly windows (#236 EZN7L2C3: the IDs are in the BigModel console; CC Switch asks the team quota with them) — a key pasted alone was asked for the team's quota (type=2) without the Bigmodel-Organization / Bigmodel-Project headers, which magpie can't find from a key, so a team key showed nothing. providers.json keeps zhipuTeam {org, project} for those key providers (trimmed; both empty removes it; a save without it keeps it, {} clears it; carried through from the Zhipu and Z.ai presets). The team query sends the typed IDs, else those of a ZCode team login holding the key, and is now asked at the key's own host (open.bigmodel.cn, as CC Switch asks it) first and bigmodel.cn (where ZCode asks it) only when that gives no windows. It still runs only when the key's personal quota fails or has no windows. The provider editor shows Team org ID / Team project ID (团队组织 ID / 团队项目 ID), optional, for Zhipu and Z.ai key providers alone, adding and editing; one without the other is refused before anything is posted. Tested with internal/provider/zhipu_team_key_test.go against a fake open.bigmodel.cn / bigmodel.cn in a temp HOME: the IDs kept trimmed, the team asked with type=2, the key and both headers at open.bigmodel.cn and read as 5 hours 42% and weekly 10%, a key without them asked with no team headers at both hosts and showing nothing, a pay-as-you-go key with blank IDs no card, Z.ai taking the fields and DeepSeek not. internal/gui/tests/zhipu-team.test.cjs, Chromium and WebKit, en and zh: the fields for a saved Zhipu provider and from the add sheet for Z.ai, none for DeepSeek or a relay, trimmed values posted, one alone refused, both empty posting {}, nothing scrolled on a click. Both fail without the change. click-scroll, old-webkit, provider-proxy and account-proxy pass; go vet, go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real team key or in the real Wails window; the team's reset counts are not read for a key (the reset list is asked only with a ZCode sign-in token).
2026-09-30 05:49:19 +08:00

332 lines
14 KiB
Go

package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// zcodeTeamUpstream is zcode.z.ai and BigModel (bigmodel.cn and
// open.bigmodel.cn) for these tests: a BigModel account with a personal
// project that has no plan, and two team coding plan projects, tp0 where
// it has no seat and tp1 where its seat is grant.
type zcodeTeamUpstream struct {
srv *httptest.Server
grant string // tp1's memberGrantStatus
mu sync.Mutex
made map[string]any // the team key made, as it was asked for
model *http.Request // the last request to the plan's endpoint
quota *http.Request // the last quota asked
}
const zcodeTeamToken = "bm-tok" // BigModel's sign-in token, as the poll hands it back
func newZCodeTeamUpstream(t *testing.T) *zcodeTeamUpstream {
t.Helper()
u := &zcodeTeamUpstream{grant: "VALID"}
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
org, proj := r.Header.Get("Bigmodel-Organization"), r.Header.Get("Bigmodel-Project")
ok := func(data any) { json.NewEncoder(w).Encode(map[string]any{"code": 200, "msg": "ok", "data": data, "success": true}) }
deny := func() { w.WriteHeader(401) }
switch p := r.URL.Path; {
case p == "/api/v1/oauth/cli/init":
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["provider"] != "bigmodel" {
w.WriteHeader(400)
return
}
ok(map[string]any{"flow_id": "f1", "authorize_url": "https://open.bigmodel.cn/oauth?client_id=c",
"expires_at": time.Now().Add(time.Minute).Unix(), "poll_interval_sec": 0})
case p == "/api/v1/oauth/cli/poll/f1":
ok(map[string]any{"status": "ready", "token": "zc", "bigmodel": map[string]any{"accessToken": zcodeTeamToken},
"user": map[string]any{"user_id": "u9", "email": "team@example.com", "name": "Team"}})
case p == "/api/auth/z/login":
deny() // BigModel's token is never exchanged
case p == "/api/biz/customer/getCustomerInfo":
if auth != zcodeTeamToken {
deny()
return
}
ok(map[string]any{"organizations": []any{
map[string]any{"organizationId": "o1", "organizationName": "默认机构", "projects": []any{
map[string]any{"projectId": "p1", "projectName": "默认项目", "projectType": "1"}}},
map[string]any{"organizationId": "t1", "organizationName": "Acme", "projects": []any{
map[string]any{"projectId": "tp0", "projectName": "old team", "projectType": 2},
map[string]any{"projectId": "tp1", "projectName": "team", "projectType": "2"}}},
}})
case p == "/api/biz/v1/organization/o1/projects/p1/api_keys" && auth == zcodeTeamToken:
ok([]any{map[string]any{"name": "zcode-api-key", "apiKey": "pk"}})
case p == "/api/biz/v1/organization/o1/projects/p1/api_keys/copy/pk" && auth == zcodeTeamToken:
ok(map[string]any{"secretKey": "ps"})
case p == "/api/biz/subscription/list":
if auth != "pk.ps" {
deny()
return
}
ok([]any{}) // no plan of its own
case p == "/api/biz/team/subscribe/product/querySubscribeDetail":
if auth != zcodeTeamToken || org != "t1" {
deny()
return
}
switch proj {
case "tp0":
ok(map[string]any{"hasSubscription": true, "status": "EFFECTIVE", "memberGrantStatus": "UNASSIGNED", "productName": "GLM Coding Team Lite"})
case "tp1":
ok(map[string]any{"hasSubscription": true, "status": "EFFECTIVE", "memberGrantStatus": u.grant, "productId": "prod-1",
"productName": "GLM Coding Team Pro", "subscribeEndTime": "2026-12-31 23:59:59"})
default:
deny()
}
case p == "/api/biz/v1/organization/t1/projects/tp1/api_keys":
if auth != zcodeTeamToken || org != "t1" || proj != "tp1" {
deny()
return
}
u.mu.Lock()
defer u.mu.Unlock()
if r.Method == http.MethodPost {
json.NewDecoder(r.Body).Decode(&u.made)
ok(map[string]any{"name": "zcode-team-api-key", "apiKey": "tk", "keyType": 2})
return
}
list := []any{map[string]any{"name": "zcode-team-api-key", "apiKey": "wrong-type", "keyType": 1}}
if u.made != nil {
list = append(list, map[string]any{"name": "zcode-team-api-key", "apiKey": "tk", "keyType": 2})
}
ok(list)
case p == "/api/biz/v1/organization/t1/projects/tp1/api_keys/copy/tk":
if auth != zcodeTeamToken || org != "t1" || proj != "tp1" {
deny()
return
}
ok(map[string]any{"secretKey": "ts"})
case p == "/api/monitor/usage/quota/limit":
u.mu.Lock()
u.quota = r
u.mu.Unlock()
if auth != "tk.ts" || r.URL.Query().Get("type") != "2" {
ok(map[string]any{"limits": []any{}})
return
}
reset := time.Now().Add(2 * time.Hour).UnixMilli()
ok(map[string]any{"limits": []any{
map[string]any{"type": "CREDIT_LIMIT", "unit": 3, "percentage": 42, "currentValue": 420, "usage": 1000, "nextResetTime": reset},
map[string]any{"type": "CREDIT_LIMIT", "unit": 6, "percentage": 10, "currentValue": 1000, "usage": 10000, "nextResetTime": reset + 86400000},
}})
case p == "/api/biz/customer-package-reset/list":
if auth != zcodeTeamToken || org != "t1" || proj != "tp1" || r.URL.Query().Get("targetType") != "TEAM" {
deny()
return
}
ok(map[string]any{
"fiveHourResets": []any{
map[string]any{"available": true, "recordId": 1, "expireTime": "2026-10-05 00:00:00", "grantType": "MONTHLY"},
map[string]any{"available": false, "recordId": 2, "expireTime": "2026-10-01 00:00:00"},
map[string]any{"available": true, "recordId": 3, "expireTime": "2026-10-20 00:00:00"},
},
"weekResets": []any{map[string]any{"available": true, "recordId": 4, "expireTime": "2026-10-10 00:00:00"}},
})
case strings.HasSuffix(p, "/v1/messages"):
u.mu.Lock()
u.model = r
u.mu.Unlock()
w.Write([]byte(`{}`))
default:
w.WriteHeader(404)
}
}))
t.Cleanup(u.srv.Close)
oldAPI, oldZai, oldBM, oldZaiBase, oldBMBase := zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI, ZCodeZaiBase, ZCodeBigModelBase
zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI = u.srv.URL, u.srv.URL, u.srv.URL
ZCodeZaiBase, ZCodeBigModelBase = u.srv.URL+"/zai/api/anthropic", u.srv.URL+"/bigmodel/api/anthropic"
t.Cleanup(func() {
zcodeAPI, zcodeZaiAPI, zcodeBigModelAPI, ZCodeZaiBase, ZCodeBigModelBase = oldAPI, oldZai, oldBM, oldZaiBase, oldBMBase
})
zcodeRoutes.Lock()
zcodeRoutes.m = map[string]zcodeRoute{}
zcodeRoutes.Unlock()
zcodeTeamKeys.Lock()
zcodeTeamKeys.m = map[string]zcodeTeamKeyAt{}
zcodeTeamKeys.Unlock()
return u
}
// #236: an account on a BigModel team's GLM Coding Plan was refused ("no
// GLM Coding Plan, and ZCode's Start Plan has ended"), since magpie only
// signed in on Z.ai and only looked for a plan of the account's own. It
// signs in on BigModel now, finds its seat and the team project's key as
// ZCode does, and is served, metered and counted on the team's plan.
func TestZCodeTeamSignIn(t *testing.T) {
signIn(t)
t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret")
u := newZCodeTeamUpstream(t)
st, err := StartSignIn("zcode:bigmodel")
if err != nil {
t.Fatal(err)
}
pu, _ := url.Parse(st.URL)
if pu.Host != "open.bigmodel.cn" || !strings.Contains(pu.Query().Get("redirect"), "zcode%3A%2F%2Foauth%2Fcallback") || pu.Query().Get("redirect_uri") != "" {
t.Fatalf("sign-in page: %s", st.URL)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
st, _ = WaitSignIn(ctx, st.ID)
if st.State != "done" || st.User != "team@example.com" || st.Plan != "GLM Coding Team Pro" {
t.Fatalf("done: %+v", st)
}
// the team key was made as ZCode makes it, the wrong type passed over
if u.made["name"] != "zcode-team-api-key" || u.made["keyType"] != float64(2) {
t.Fatalf("team key made: %v", u.made)
}
ls := zcodeLogins()
if len(ls) != 1 || ls[0].key.Key != "tk.ts" || ls[0].key.Org != "t1" || ls[0].key.Project != "tp1" || ls[0].key.Token != zcodeTeamToken ||
ls[0].key.Base != ZCodeBigModelBase || ls[0].key.JWT != "zc" {
t.Fatalf("saved: %+v", ls)
}
// served at BigModel's coding plan endpoint with the team key, never the
// Start Plan's, though it holds ZCode's token
p, ok := find(All(), "zcode")
if !ok || p.Anthropic != ZCodeBigModelBase {
t.Fatalf("provider: %+v", p)
}
req, _ := http.NewRequest("POST", p.Anthropic+"/v1/messages", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer magpie")
if err := p.Sign(context.Background(), req, Anthropic, []byte(`{}`)); err != nil ||
req.Header.Get("x-api-key") != "tk.ts" || req.Header.Get("Authorization") != "Bearer tk.ts" ||
!strings.HasPrefix(req.URL.String(), ZCodeBigModelBase) || req.Header.Get("X-ZCode-App-Version") != "" {
t.Fatalf("signed: %v %s %v", err, req.URL, req.Header)
}
if zcodeOnStart(context.Background(), ls[0].key) {
t.Fatal("a team seat went to the Start Plan")
}
// its usage: the team's five hours and week, its resets, its plan's end
q := LoginUsage(context.Background(), "zcode")["team@example.com"]
if q.Error != "" || q.Plan != "GLM Coding Team Pro" || len(q.Windows) != 2 {
t.Fatalf("usage: %+v", q)
}
five, week := q.Windows[0], q.Windows[1]
if five.Name != "5 hours" || five.Used != 42 || five.Span != 5*time.Hour || five.ResetsAt == nil || five.Display != "420 / 1000" {
t.Fatalf("five hours: %+v", five)
}
if week.Name != "Weekly" || week.Used != 10 || week.Span != 7*24*time.Hour {
t.Fatalf("week: %+v", week)
}
qr := u.quota
if qr.Header.Get("Bigmodel-Organization") != "t1" || qr.Header.Get("Bigmodel-Project") != "tp1" || qr.Header.Get("Accept-Language") == "" || qr.Header.Get("Set-Language") == "" {
t.Fatalf("quota asked with: %v", qr.Header)
}
r := q.Resets
if r == nil || !r.ByWindow || r.FiveHour != 2 || r.Weekly != 1 || r.Count != 3 || r.Until == nil || r.Until.Format("2006-01-02") != "2026-10-05" {
t.Fatalf("resets: %+v", r)
}
if got := r.Words(); got != "2 five-hour resets · 1 weekly reset" {
t.Fatalf("resets in words: %q", got)
}
if q.Until == nil || q.Until.Format("2006-01-02") != "2026-12-31" || q.Renew != "off" {
t.Fatalf("term: %v %q", q.Until, q.Renew)
}
}
// A GLM key's team windows are asked where ZCode asks them, with the team
// project's headers when the key is a team seat's magpie holds.
func TestZhipuKeyTeamWindows(t *testing.T) {
signIn(t)
t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret")
u := newZCodeTeamUpstream(t)
k, plan, err := zcodeSignedIn(context.Background(), "bigmodel", zcodeTeamToken, "")
if err != nil || plan != "GLM Coding Team Pro" {
t.Fatalf("signed in: %v %q", err, plan)
}
auth, _ := json.Marshal(k)
if err := addSideLogin(savedLogin{Agent: "zcode", User: "team@example.com", Plan: plan, Auth: auth}, "", func(savedLogin) {}); err != nil {
t.Fatal(err)
}
plan, ws, err := zhipuKeyTeamWindows(context.Background(), ZCodeBigModelBase, "tk.ts", nil)
if err != nil || plan != "GLM Coding Team" || len(ws) != 2 || ws[0].Name != "5 hours" || ws[0].Used != 42 || ws[0].Span != 5*time.Hour || ws[1].Name != "7 days" {
t.Fatalf("windows: %v %q %+v", err, plan, ws)
}
if u.quota.Header.Get("Bigmodel-Project") != "tp1" {
t.Fatalf("asked with: %v", u.quota.Header)
}
// a key that isn't a seat magpie holds is asked without them, and has none
if _, ws, err := zhipuKeyTeamWindows(context.Background(), ZCodeBigModelBase, "other.key", nil); err != nil || len(ws) != 0 {
t.Fatalf("another key: %v %+v", err, ws)
}
}
// A team member with no seat, and no plan or Start Plan besides, is told
// why rather than that there is no plan at all.
func TestZCodeTeamNoSeat(t *testing.T) {
signIn(t)
u := newZCodeTeamUpstream(t)
u.grant = "UNASSIGNED"
_, _, err := zcodeSignedIn(context.Background(), "bigmodel", zcodeTeamToken, "")
if err == nil || !strings.Contains(err.Error(), "no seat") || !strings.Contains(err.Error(), "BigModel") {
t.Fatalf("no seat: %v", err)
}
if u.made != nil {
t.Fatalf("a key was made without a seat: %v", u.made)
}
}
// ZCode's own account, switched in ZCode to a team's plan, keeps no team
// key: magpie finds it as ZCode does, from ZCode's sign-in and the project
// its settings name.
func TestZCodeOwnTeam(t *testing.T) {
home := signIn(t)
t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret")
u := newZCodeTeamUpstream(t)
writeFile(t, filepath.Join(home, ".zcode", "v2", "credentials.json"), map[string]any{
"oauth:bigmodel:user_info": zcodeEncrypt(t, `{"user_id":"u9","email":"team@example.com"}`),
"oauth:bigmodel:access_token": zcodeEncrypt(t, zcodeTeamToken),
"zcodejwttoken": zcodeEncrypt(t, "zc"),
})
writeFile(t, filepath.Join(home, ".zcode", "v2", "setting.json"), map[string]any{
"providerFamilyDomain": "bigmodel",
"providerFamilyConnectionSelections": map[string]any{
"bigmodel": map[string]any{"kind": "team-coding-plan", "productId": "prod-1", "organizationId": "t1", "projectId": "tp1"},
},
})
who, k, ok := zcodeOwn()
if !ok || who != "team@example.com" || k.Key != "" || k.Org != "t1" || k.Project != "tp1" || k.Base != ZCodeBigModelBase || k.JWT != "zc" {
t.Fatalf("own: %v %q %+v", ok, who, k)
}
p, ok := find(All(), "zcode")
if !ok || p.Anthropic != ZCodeBigModelBase {
t.Fatalf("provider: %+v", p)
}
req, _ := http.NewRequest("POST", p.Anthropic+"/v1/messages", strings.NewReader(`{}`))
if err := p.Sign(context.Background(), req, Anthropic, []byte(`{}`)); err != nil || req.Header.Get("x-api-key") != "tk.ts" {
t.Fatalf("signed: %v %v", err, req.Header)
}
if u.made == nil {
t.Fatal("the team key wasn't found or made")
}
q := LoginUsage(context.Background(), "zcode")["team@example.com"]
if q.Error != "" || len(q.Windows) != 2 || q.Windows[0].Used != 42 || q.Resets == nil || q.Resets.FiveHour != 2 {
t.Fatalf("usage: %+v", q)
}
// ZCode on its individual plan again: as before
writeFile(t, filepath.Join(home, ".zcode", "v2", "setting.json"), map[string]any{
"providerFamilyDomain": "bigmodel",
"providerFamilyConnectionSelections": map[string]any{"bigmodel": map[string]any{"kind": "individual-coding-plan"}},
})
if _, k, ok := zcodeOwn(); !ok || k.team() {
t.Fatalf("own, individual: %v %+v", ok, k)
}
}