Files
magpie/internal/provider/zcode_start_test.go
T
yetone 11be3f31b0 zcode: a Start Plan request goes as the ZCode CLI sends it, not the desktop app (miaopasi and ARNO on Discord: Start plan还是405 感觉不是IP的问题; an OpenCode plugin sending the CLI's request works on the same plan) — dressed as the desktop app, some Start Plan requests were still turned away with 405 "request has been blocked due to unusual activity", code 3012. Following ZCode 3.14.3's own source (github.com/zai-org/ZCode, apps/zcode-cli/packages/core/src/context/builder.ts and sections/, bootstrap/src/model-config.ts and runtime-platform-headers.ts, adapters/src/model/runner-attribution.ts), the request is now the CLI's: X-Title "Z Code@cli" (was "Z Code@electron", which only the desktop app's agent server sends); the stable system block is the identity section alone, the "ZCode Desktop Context" section being added only for presentationSurface zcode_desktop; X-Platform is <platform>-<arch> and the new X-Os-Version is os.release(), as runtime-platform-headers sets them (Darwin's uname release, Windows' Major.Minor.Build from RtlGetVersion); the environment block's OS Version is "<platform> <release> <arch>" as env-info.ts writes it (was "unknown") and its powered-by line is the provider id and model as ZCode names them (account:zai-start-plan/GLM-5.3-Flash, account:bigmodel-start-plan on BigModel; was lowercased without the provider); the currentDate reminder is wrapped as system-reminder/source.ts wraps a context prefix (a newline after the opening tag, one after the closing). Model requests no longer carry X-Device-Mid (ZCode sends it only to its own API, where the balance still needs it, #282) nor x-api-key: the JWT goes as a Bearer only, as the plugin that works sends it (the official client passes it as the SDK's apiKey too, so this follows the working plugin over the source). The prompt text itself was checked byte for byte against the 3.14.3 sections (rendered with node). The plugin's own source wasn't found (pi-freeflow has no zcode code), so where it and the source might differ (X-Os-Version as release alone, not "<platform> <release> <arch>") the source wins. No request was made to zcode.z.ai. TestZCodeStartSentAsTheApp, TestZCodeStartPlanOwnAccount and TestZCodeSignInStartPlanDeviceMid are updated; with the old prompt, X-Title and x-api-key they fail (X-Title "Z Code@electron", X-Api-Key sent, the desktop section in the system prompt). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider/ ./internal/gateway/ pass.
2026-10-02 13:17:26 +08:00

250 lines
10 KiB
Go

package provider
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"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
body []byte // and its body
}
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 r.Header.Get("X-Device-Mid") == "" { // as zcode.z.ai does (#282)
w.WriteHeader(400)
w.Write([]byte(`{"code":3001,"msg":"parameter error"}`))
return
}
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
u.body, _ = io.ReadAll(r.Body)
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") != "" || 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("Authorization") != "Bearer "+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", "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", "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", "zai-tok", jwt); err == nil || !strings.Contains(err.Error(), "Start Plan") {
t.Fatalf("neither: %v", err)
}
if _, _, err := zcodeSignedIn(ctx, "zai", "zai-tok", ""); err == nil {
t.Fatal("no token, no plan: signed in")
}
}