Files
magpie/internal/provider/zcode_start_test.go
T
yetone 9992678c64 zcode: an account with no GLM Coding Plan uses ZCode's Start Plan (体验套餐) where ZCode does, with ZCode's own token, and shows its allowance (#236 tasselx: ZCode 3.14.4, magpie 0.1.410, a trial-only account, the free quota not read and every request refused with rate_limit_error "ZCode: [1113][Insufficient balance or no resource package. Please recharge.]"; #225 xiaozhu1337: ZCode ships a built-in trial package) — magpie sent every ZCode account to the Coding Plan's endpoint, api.z.ai/api/anthropic, with the account's zcode-api-key, and read its allowance only from /api/monitor/usage/quota/limit. That key is billed to a Coding Plan, or else to the account's API balance, so an account with only the Start Plan got 1113. ZCode itself (ZCode.app 3.14.3's zcode-builtin.json providerRules and host/index.js: resolveCurrent, buildZaiStartPlanBalanceUrl, hasActiveStartPlan, isZaiStartPlanIdentity, normalizeStartPlanExpiry, normalizeZaiStartPlanBalanceLimits, pollPendingOAuth) serves the Start Plan (account:zai-start-plan and account:bigmodel-start-plan) from zcode.z.ai/api/v1/zcode-plan/anthropic, keyed by its own session token (the credential store's zcodejwttoken, the token its sign-in's poll hands back, which magpie dropped), and reads what is left from /api/v1/zcode-plan/billing/balance?app_version= with that token. Now, in a new zcode_start.go: an account keeps ZCode's token beside its key (zcodeKey.JWT; zcodeOwn reads zcodejwttoken, and user_info from oauth:bigmodel:user_info when there is no Z.ai one; an account with the token and no coding plan key is an account). Whether it is on the Start Plan is asked of /api/biz/subscription/list (a Coding Plan: as before; none: the Start Plan; can't tell: the Start Plan if its balance has an active one), kept 10 minutes (1 when unsure); with no token it is the Coding Plan as before, asking nothing, and with no key it is the Start Plan. On the Start Plan a request is moved to zcode-plan/anthropic, signed with the token (x-api-key and Bearer) and ZCode's own headers (User-Agent ZCode/<ver>, X-ZCode-App-Version, X-Title, HTTP-Referer, X-Platform), refused with "sign in to ZCode again" once the token has run out; its models are the Start Plan's (zcodePlanID account:zai-start-plan for ZCode's config, zcodeModels less GLM-5.3 before it is read). Its allowance is the balance as ZCode reads it: a plan still "active" past ends_at (by server_time) is expired and the buckets of expired plans left out; the plan's name (Start Plan) with Until its end and Renew off; a window per bucket, named by its show_name (or its models, or Credits), Used and "used / total" from total/used/remaining units (numbers or strings), ResetsAt its expires_at, Span from its entitlement's period (daily/weekly/monthly) or period_start..end, and matched to the bucket's models. Signing in from magpie no longer refuses an account with no Coding Plan: with an active Start Plan it is added on that (its key kept too, so a Coding Plan bought later is used), and refused only when the Start Plan is over as well ("this Z.ai account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started — subscribe at z.ai/subscribe, then add it again"). Accounts with a Coding Plan are routed, signed and metered as before. The provider keeps the name ZCode (#225 asked for "GLM Coding Plan"; ZCode names these plans "Z.AI Individual Coding Plan" and "Start Plan", and the plan line already shows "GLM Coding <Level>"). Tested with a new internal/provider/zcode_start_test.go against a fake Z.ai/zcode.z.ai in a sandbox HOME, no real tokens: ZCode signed in with only its token (BigModel user_info) is an account, its provider served at zcode-plan/anthropic, a request signed with the token and ZCode's headers, the Start Plan's models and plan id, its allowance (Start Plan, Until, Renew off, GLM-5.1's window 25%, "250000 / 1000000", reset, a day's span, matching GLM-5.1 only); an expired plan and one active past its end give none; with a key and the token, no Coding Plan moves the request (query kept) to the Start Plan and it reaches it with the token, a Coding Plan keeps it on api.z.ai with the key and no ZCode headers, no token asks nothing, an expired token is refused; a sign-in with only the Start Plan is added on it, with a Coding Plan on that plan, with neither refused naming both. TestZCodeAccounts passes unchanged. go build -tags nogui (and GOOS=linux, GOOS=windows), go vet and go test -tags nogui ./... pass (TestUpdateCLI, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin and TestUserPath timed out once each under load beside other builds and pass alone). Not tried with a real Start Plan account: the endpoint's acceptance of the token as x-api-key/Bearer and whether it needs ZCode's headers, the balance's real field values and period strings, what subscription/list returns for a trial-only account, and ZCode 3.14.4 (read from 3.14.3) are from ZCode's code, not seen on the wire.
2026-09-30 02:15:50 +08:00

242 lines
10 KiB
Go

package provider
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// zcodeTestJWT is a token shaped as ZCode's, running out at exp.
func zcodeTestJWT(exp time.Time) string {
b := base64.RawURLEncoding.EncodeToString
claims, _ := json.Marshal(map[string]any{"sub": "u1", "exp": exp.Unix()})
return b([]byte(`{"alg":"HS256","typ":"JWT"}`)) + "." + b(claims) + ".sig"
}
// zcodeStartUpstream is Z.ai and zcode.z.ai for these tests: plans is what
// /api/biz/subscription/list says of every key, and balance what
// /api/v1/zcode-plan/billing/balance says to jwt.
type zcodeStartUpstream struct {
srv *httptest.Server
plans []any
balance map[string]any
jwt string
model *http.Request // the last request to the Start Plan's endpoint
}
func newZCodeStartUpstream(t *testing.T, jwt string) *zcodeStartUpstream {
t.Helper()
u := &zcodeStartUpstream{jwt: jwt, plans: []any{}}
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
ok := func(data any) { json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": data}) }
switch {
case r.URL.Path == "/api/auth/z/login":
ok(map[string]any{"access_token": "biz"})
case auth == "Bearer biz" && r.URL.Path == "/api/biz/customer/getCustomerInfo":
ok(map[string]any{"organizations": []any{map[string]any{"organizationId": "o1", "organizationName": "默认机构",
"projects": []any{map[string]any{"projectId": "p1", "projectName": "默认项目", "projectType": "1"}}}}})
case auth == "Bearer biz" && r.URL.Path == "/api/biz/v1/organization/o1/projects/p1/api_keys":
ok([]any{map[string]any{"name": "zcode-api-key", "apiKey": "key"}})
case auth == "Bearer biz" && r.URL.Path == "/api/biz/v1/organization/o1/projects/p1/api_keys/copy/key":
ok(map[string]any{"secretKey": "secret"})
case r.URL.Path == "/api/biz/subscription/list":
if auth != "key.secret" {
w.WriteHeader(401)
return
}
ok(u.plans)
case r.URL.Path == "/api/v1/zcode-plan/billing/balance":
if auth != "Bearer "+u.jwt || r.URL.Query().Get("app_version") == "" {
w.WriteHeader(401)
return
}
ok(u.balance)
case strings.HasPrefix(r.URL.Path, "/api/v1/zcode-plan/anthropic/"):
u.model = r
w.Write([]byte(`{}`))
default:
w.WriteHeader(404)
}
}))
t.Cleanup(u.srv.Close)
oldAPI, oldZai, oldBase := zcodeAPI, zcodeZaiAPI, ZCodeZaiBase
zcodeAPI, zcodeZaiAPI, ZCodeZaiBase = u.srv.URL, u.srv.URL, u.srv.URL+"/api/anthropic"
t.Cleanup(func() { zcodeAPI, zcodeZaiAPI, ZCodeZaiBase = oldAPI, oldZai, oldBase })
zcodeRoutes.Lock()
zcodeRoutes.m = map[string]zcodeRoute{}
zcodeRoutes.Unlock()
return u
}
// a Start Plan with one bucket, GLM-5.1's tokens for the day, a quarter used
func zcodeActiveStart(now time.Time, status string) map[string]any {
return map[string]any{
"server_time": now.Unix(),
"plans": []any{map[string]any{"plan_id": "zai-start-plan", "user_plan_id": "up1", "name": "Start Plan", "status": status,
"ends_at": now.Add(7 * 24 * time.Hour).Unix(),
"entitlements": []any{map[string]any{"entitlement_id": "e1", "period": "daily"}}}},
"balances": []any{map[string]any{"plan_id": "zai-start-plan", "user_plan_id": "up1", "entitlement_id": "e1",
"show_name": "GLM-5.1", "capabilities": []any{"model:GLM-5.1"},
"total_units": "1000000", "used_units": 250000, "remaining_units": 750000,
"expires_at": now.Add(time.Hour).Unix()}},
}
}
// #236: ZCode signed in to an account with only its Start Plan (体验套餐)
// keeps no coding plan key, just its own session token. magpie reads that
// account, sends its requests where ZCode does, with that token, and shows
// the Start Plan's allowance.
func TestZCodeStartPlanOwnAccount(t *testing.T) {
home := signIn(t)
t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret")
jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour))
u := newZCodeStartUpstream(t, jwt)
now := time.Now()
u.balance = zcodeActiveStart(now, "active")
writeFile(t, filepath.Join(home, ".zcode", "v2", "credentials.json"), map[string]any{
"oauth:bigmodel:user_info": zcodeEncrypt(t, `{"user_id":"u1","email":"trial@example.com"}`),
"zcodejwttoken": zcodeEncrypt(t, "Bearer "+jwt),
"oauth:active_provider": zcodeEncrypt(t, "bigmodel"),
})
who, k, ok := zcodeOwn()
if !ok || who != "trial@example.com" || k.Key != "" || k.JWT != jwt {
t.Fatalf("own: %v %q %+v", ok, who, k)
}
p, ok := find(All(), "zcode")
if !ok || p.Account.User != "trial@example.com" || p.Anthropic != u.srv.URL+"/api/v1/zcode-plan/anthropic" {
t.Fatalf("provider: %v %+v", ok, p)
}
req, _ := http.NewRequest("POST", p.Anthropic+"/v1/messages", nil)
req.Header.Set("Authorization", "Bearer magpie")
if err := p.Sign(context.Background(), req, Anthropic, []byte(`{}`)); err != nil ||
req.Header.Get("x-api-key") != jwt || req.Header.Get("Authorization") != "Bearer "+jwt ||
req.Header.Get("X-ZCode-App-Version") == "" || req.URL.Path != "/api/v1/zcode-plan/anthropic/v1/messages" {
t.Fatalf("signs with ZCode's token: %v %s %v", err, req.URL, req.Header)
}
if ms := p.Account.models(); len(ms) != len(zcodeStartModels) {
t.Fatalf("models: %+v", ms)
}
if id := zcodePlanID(p.Anthropic); id != "account:zai-start-plan" {
t.Fatalf("plan id: %s", id)
}
q := LoginUsage(context.Background(), "zcode")["trial@example.com"]
if q.Error != "" || q.Plan != "Start Plan" || q.Until == nil || q.Renew != "off" || len(q.Windows) != 1 {
t.Fatalf("usage: %+v", q)
}
w := q.Windows[0]
if w.Name != "GLM-5.1" || w.Used != 25 || w.Display != compactNumber(250000)+" / "+compactNumber(1000000) ||
w.ResetsAt == nil || w.Span != 24*time.Hour || w.matches == nil || !w.matches("glm-5.1") || w.matches("GLM-5-Turbo") {
t.Fatalf("window: %+v", w)
}
// the Start Plan over: none of its buckets, and it says so
u.balance = zcodeActiveStart(now, "expired")
if q := zcodeQuota(context.Background(), Login{User: "trial@example.com"}, k); q.Error == "" || len(q.Windows) != 0 {
t.Fatalf("usage over: %+v", q)
}
// still "active" past its end is over too, as ZCode reads it
u.balance = zcodeActiveStart(now, "active")
u.balance["server_time"] = now.Add(8 * 24 * time.Hour).Unix()
if b, err := zcodeStartBalance(context.Background(), jwt); err != nil || len(b.Balances) != 0 {
t.Fatalf("past its end: %v %+v", err, b)
} else if _, _, ok := b.active(); ok {
t.Fatal("past its end is active")
}
}
// An account with a coding plan key and ZCode's token goes to the Coding
// Plan while it has one, and to the Start Plan when it has none: the 1113
// "Insufficient balance or no resource package" of #236 was the key's
// requests going to the Coding Plan's endpoint with no Coding Plan.
func TestZCodeStartPlanRouting(t *testing.T) {
signIn(t)
jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour))
u := newZCodeStartUpstream(t, jwt)
u.balance = zcodeActiveStart(time.Now(), "active")
k := zcodeKey{Key: "key.secret", Base: ZCodeZaiBase, JWT: jwt}
sign := func() *http.Request {
t.Helper()
p := zcodeProvider("a@example.com", "", k)
req, _ := http.NewRequest("POST", p.Anthropic+"/v1/messages?beta=true", nil)
if err := p.Sign(context.Background(), req, Anthropic, []byte(`{}`)); err != nil {
t.Fatal(err)
}
return req
}
// no Coding Plan: the Start Plan, with ZCode's token
req := sign()
if req.URL.String() != u.srv.URL+"/api/v1/zcode-plan/anthropic/v1/messages?beta=true" || req.Header.Get("x-api-key") != jwt {
t.Fatalf("no coding plan: %s %v", req.URL, req.Header)
}
if q := zcodeQuota(context.Background(), Login{User: "a@example.com"}, k); q.Plan != "Start Plan" || len(q.Windows) != 1 {
t.Fatalf("quota: %+v", q)
}
// and the request reaches it
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode != 200 || u.model == nil || u.model.Header.Get("Authorization") != "Bearer "+jwt {
t.Fatalf("sent: %v %v", err, res)
}
res.Body.Close()
// with a Coding Plan: api.z.ai with the key, as before
u.plans = []any{map[string]any{"productName": "GLM Coding Lite", "status": "VALID"}}
zcodeRoutes.Lock()
zcodeRoutes.m = map[string]zcodeRoute{}
zcodeRoutes.Unlock()
req = sign()
if req.URL.String() != u.srv.URL+"/api/anthropic/v1/messages?beta=true" || req.Header.Get("x-api-key") != "key.secret" || req.Header.Get("X-ZCode-App-Version") != "" {
t.Fatalf("coding plan: %s %v", req.URL, req.Header)
}
// with no ZCode token at all: the Coding Plan, asking nothing
if zcodeOnStart(context.Background(), zcodeKey{Key: "x.y", Base: ZCodeZaiBase}) {
t.Fatal("no token on the Start Plan")
}
// ZCode's token run out: sign in again
old := zcodeKey{Base: ZCodeZaiBase, JWT: zcodeTestJWT(time.Now().Add(-time.Hour))}
p := zcodeProvider("b@example.com", "", old)
req, _ = http.NewRequest("POST", p.Anthropic+"/v1/messages", nil)
if err := p.Sign(context.Background(), req, Anthropic, nil); !errors.Is(err, errZCodeExpired) {
t.Fatalf("expired: %v", err)
}
}
// Signing in to an account with no Coding Plan was refused ("no GLM Coding
// Plan"); with ZCode's Start Plan it is added on that, and refused, saying
// both, when that is over too.
func TestZCodeSignInStartPlan(t *testing.T) {
signIn(t)
jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour))
u := newZCodeStartUpstream(t, jwt)
u.balance = zcodeActiveStart(time.Now(), "active")
ctx := context.Background()
k, plan, err := zcodeSignedIn(ctx, "zai-tok", jwt)
if err != nil || plan != "Start Plan" || k.Key != "key.secret" || k.JWT != jwt || !zcodeOnStart(ctx, k) {
t.Fatalf("start plan: %v %q %+v", err, plan, k)
}
u.plans = []any{map[string]any{"productName": "GLM Coding Pro", "status": "VALID"}}
if k, plan, err := zcodeSignedIn(ctx, "zai-tok", jwt); err != nil || plan != "GLM Coding Pro" || k.JWT != jwt {
t.Fatalf("coding plan: %v %q %+v", err, plan, k)
}
u.plans = []any{}
u.balance = zcodeActiveStart(time.Now(), "expired")
if _, _, err := zcodeSignedIn(ctx, "zai-tok", jwt); err == nil || !strings.Contains(err.Error(), "Start Plan") {
t.Fatalf("neither: %v", err)
}
if _, _, err := zcodeSignedIn(ctx, "zai-tok", ""); err == nil {
t.Fatal("no token, no plan: signed in")
}
}