Files
magpie/internal/provider/zcode_start.go
T
yetone 986a4474fe zcode: the Start Plan's 405 block is said for what it is, not only as a firewall (#425: ZCode 405 Method Not Allowed on GLM-5.3-Flash from DeepSeek Harness, while zcode2api works with the same account) — zcode.z.ai answers a Start Plan request it turns away with 405 "request has been blocked due to unusual activity" (code 3012 "method not allowed"), which magpie read as Alibaba Cloud's firewall blocking the address and told the user to wait or switch network. zcode2api's own write-ups (a137460387/zcode2api "3012 的根因与修复", gakiyukr/zcode2api-plus request.go) find the endpoint checks what a request carries and takes only one with ZCode's own system prompt, which zcode2api injects; magpie's endpoint, method and protocol are the same as theirs, and magpie doesn't pretend to be ZCode. On a Start Plan account the 405 / 3012 / block page now reads "ZCode's Start Plan turns away requests that don't come from the ZCode app itself, and magpie doesn't pretend to be it; it can also be a network block of this IP. Use an account with a GLM Coding Plan, or add another provider to this group", in place of the generic hint, to the agent and in the trace, where the Routing page gives it on its own line in English or Chinese; the group still goes on to its next member. A Coding Plan account and every other provider keep the generic hint. Requests, headers and prompts are unchanged. TestZCodeStartBlockedSaid and routing-zcode-blocked.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, a load flake, passes alone); routing-zcode-blocked, routing-blocked, routing-wb-refused, click-scroll, old-webkit pass.
2026-10-01 20:45:11 +08:00

386 lines
12 KiB
Go

package provider
// ZCode's Start Plan (体验套餐) is the free allowance ZCode gives a Z.ai or
// BigModel account that has no GLM Coding Plan. It is not served where the
// Coding Plan is: ZCode sends its requests to zcode.z.ai itself,
// /api/v1/zcode-plan/anthropic, with its own session token (the
// credential store's zcodejwttoken, the `token` its sign-in's poll hands
// back) as the key, and reads what is left from
// /api/v1/zcode-plan/billing/balance with the same token. The account's
// zcode-api-key on api.z.ai/api/anthropic is billed to the Coding Plan, or
// else to the account's API balance, and refused with 1113 "Insufficient
// balance or no resource package" when there is neither.
//
// So an account goes to the Start Plan when it has ZCode's token and no
// Coding Plan (as /api/biz/subscription/list says), or has no Coding Plan
// key at all; to the Coding Plan otherwise, as before.
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// zcodeStartBase is where the Start Plan is served.
func zcodeStartBase() string { return zcodeAPI + "/api/v1/zcode-plan/anthropic" }
// zcodeStartModels are the Start Plan's models before ZCode's config is
// read: its builtinModelIds for account:zai-start-plan.
var zcodeStartModels = func() []catalog.Model {
var out []catalog.Model
for _, m := range zcodeModels {
if m.ID != "GLM-5.3" {
out = append(out, m)
}
}
return out
}()
// ZCodeStartBlockedHint is what the Start Plan's "request has been blocked
// due to unusual activity" (HTTP 405, code 3012 "method not allowed")
// means (#425): zcode.z.ai looks at what a request carries and turns away
// one that isn't the ZCode app's own, its system prompt and all, as
// zcode2api found; it can still be Alibaba Cloud's firewall blocking the
// address (BlockedHint). magpie sends the agent's request as it is.
const ZCodeStartBlockedHint = "ZCode's Start Plan turns away requests that don't come from the ZCode app itself, and magpie doesn't pretend to be it; it can also be a network block of this IP. Use an account with a GLM Coding Plan, or add another provider to this group"
// zcodeStartRefused matches that refusal: the block page, or its code.
var zcodeStartRefused = regexp.MustCompile(`(?i)unusual activity|"code"\s*:\s*"?3012\b`)
// zcodeStartExplain gives ZCodeStartBlockedHint for the Start Plan's block.
func zcodeStartExplain(status int, body []byte) string {
if status >= 400 && (status == http.StatusMethodNotAllowed || EdgeBlocked(body) || zcodeStartRefused.Match(body)) {
return ZCodeStartBlockedHint
}
return ""
}
// ---- which plan -----------------------------------------------------------------
var zcodeRoutes = struct {
sync.Mutex
m map[string]zcodeRoute
}{m: map[string]zcodeRoute{}}
type zcodeRoute struct {
start bool
at time.Time
ttl time.Duration
}
// zcodeOnStart says whether k's requests go to the Start Plan, asking
// when the last answer is old; with ctx nil it only says what was found
// last (the Coding Plan when nothing was).
func zcodeOnStart(ctx context.Context, k zcodeKey) bool {
if k.JWT == "" || k.team() { // a team's seat is on the team's plan (zcode_team.go)
return false
}
if k.Key == "" {
return true
}
id := k.Key + "\x00" + k.JWT
zcodeRoutes.Lock()
r, ok := zcodeRoutes.m[id]
zcodeRoutes.Unlock()
if ctx == nil || ok && time.Since(r.at) < r.ttl {
return r.start
}
start, sure := zcodeDecide(ctx, k)
ttl := 10 * time.Minute
if !sure {
ttl = time.Minute
}
zcodeRoutes.Lock()
zcodeRoutes.m[id] = zcodeRoute{start: start, at: time.Now(), ttl: ttl}
zcodeRoutes.Unlock()
return start
}
// zcodeDecide asks whether the account has a Coding Plan: with one it is
// used; with none the Start Plan is all the account may have. When that
// can't be told, the Start Plan is used if it is there.
func zcodeDecide(ctx context.Context, k zcodeKey) (start, sure bool) {
plan, err := zcodePlan(ctx, k)
switch {
case err == nil && plan != "":
return false, true
case err == nil:
return true, true
}
if b, err := zcodeStartBalance(ctx, k.JWT); err == nil {
if _, _, ok := b.active(); ok {
return true, true
}
}
return false, false
}
// zcodeRebase moves a request made for one plan's endpoint to another's.
func zcodeRebase(req *http.Request, from, to string) {
if from == to || from == "" {
return
}
rest, ok := strings.CutPrefix(req.URL.String(), from)
if !ok {
return
}
if u, err := url.Parse(to + rest); err == nil {
req.URL, req.Host = u, u.Host
}
}
// zcodeSourceHeaders are the headers ZCode names itself with on a model
// request.
func zcodeSourceHeaders(req *http.Request) {
platform := runtime.GOOS
if platform == "windows" {
platform = "win32"
}
arch := runtime.GOARCH
if arch == "amd64" {
arch = "x64"
}
req.Header.Set("User-Agent", "ZCode/"+zcodeAppVersion)
req.Header.Set("X-ZCode-App-Version", zcodeAppVersion)
req.Header.Set("X-Title", "Z Code@electron")
req.Header.Set("HTTP-Referer", zcodeAPI)
req.Header.Set("X-Platform", platform+"-"+arch)
zcodeDeviceHeader(req)
}
// zcodeJWTExpired says whether ZCode's token has run out; ZCode then asks
// to sign in again, and so does magpie.
func zcodeJWTExpired(jwt string) bool {
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
return false
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return false
}
var claims struct {
Exp float64 `json:"exp"`
}
if json.Unmarshal(b, &claims) != nil || claims.Exp == 0 {
return false
}
return time.Now().After(time.Unix(int64(claims.Exp), 0))
}
var errZCodeExpired = errors.New("ZCode's sign-in has expired; sign in to ZCode again (or add the account again in magpie)")
// ---- the allowance ----------------------------------------------------------------
type zcodeBalance struct {
ServerTime any `json:"server_time"`
Plans []struct {
PlanID string `json:"plan_id"`
UserPlanID string `json:"user_plan_id"`
Name string `json:"name"`
Status string `json:"status"`
EndsAt any `json:"ends_at"`
Entitlements []struct {
ID string `json:"entitlement_id"`
Period string `json:"period"`
} `json:"entitlements"`
} `json:"plans"`
Balances []struct {
PlanID string `json:"plan_id"`
UserPlanID string `json:"user_plan_id"`
Entitlement string `json:"entitlement_id"`
ShowName string `json:"show_name"`
Capabilities []string `json:"capabilities"`
Total any `json:"total_units"`
Used any `json:"used_units"`
Remaining any `json:"remaining_units"`
ExpiresAt any `json:"expires_at"`
PeriodStart any `json:"period_start"`
PeriodEnd any `json:"period_end"`
} `json:"balances"`
}
// zcodeNum reads a number the balance gives as a number or a string.
func zcodeNum(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
}
return 0, false
}
// zcodeStartBalance is the Start Plan's balance, as ZCode reads it: a plan
// still "active" past its end is over, and the buckets of plans that are
// over are left out.
func zcodeStartBalance(ctx context.Context, jwt string) (zcodeBalance, error) {
var b zcodeBalance
if jwt == "" {
return b, errors.New("not signed in to ZCode")
}
if zcodeJWTExpired(jwt) {
return b, errZCodeExpired
}
u := zcodeAPI + "/api/v1/zcode-plan/billing/balance?" + url.Values{"app_version": {zcodeAppVersion}}.Encode()
if err := zcodeCall(ctx, http.MethodGet, u, "Bearer "+jwt, nil, &b); err != nil {
return b, err
}
now := float64(time.Now().Unix())
if t, ok := zcodeNum(b.ServerTime); ok && t > 0 {
now = t
}
over := map[string]bool{}
for i, p := range b.Plans {
if end, ok := zcodeNum(p.EndsAt); ok && end > 0 && end <= now && strings.EqualFold(strings.TrimSpace(p.Status), "active") {
b.Plans[i].Status = "expired"
}
if strings.EqualFold(strings.TrimSpace(b.Plans[i].Status), "expired") {
over[p.UserPlanID+"\x00"+p.PlanID] = true
}
}
bs := b.Balances[:0]
for _, x := range b.Balances {
keep := true
for _, p := range b.Plans {
if x.UserPlanID != "" && p.UserPlanID != "" && x.UserPlanID == p.UserPlanID || (x.UserPlanID == "" || p.UserPlanID == "") && x.PlanID == p.PlanID {
keep = !over[p.UserPlanID+"\x00"+p.PlanID]
if keep {
break
}
}
}
if keep {
bs = append(bs, x)
}
}
b.Balances = bs
return b, nil
}
// active is the Start Plan the account has now, as ZCode tells it: an
// active plan whose id or name says start plan (or that has neither).
func (b zcodeBalance) active() (name string, until *time.Time, ok bool) {
for _, p := range b.Plans {
if !strings.EqualFold(strings.TrimSpace(p.Status), "active") {
continue
}
id, n := strings.ToLower(strings.TrimSpace(p.PlanID)), strings.ToLower(strings.TrimSpace(p.Name))
isStart := func(s string) bool { return strings.Contains(s, "start-plan") || strings.Contains(s, "start plan") }
if id != "" || n != "" {
if !isStart(id) && !isStart(n) {
continue
}
}
if end, ok := zcodeNum(p.EndsAt); ok && end > 0 {
t := time.Unix(int64(end), 0)
until = &t
}
return firstNonEmpty(p.Name, "Start Plan"), until, true
}
return "", nil, false
}
// zcodePeriod reads an entitlement's period: "daily", "weekly", "monthly".
func zcodePeriod(p string) time.Duration {
p = strings.ToLower(p)
switch {
case strings.Contains(p, "day") || strings.Contains(p, "daily"):
return 24 * time.Hour
case strings.Contains(p, "week"):
return 7 * 24 * time.Hour
case strings.Contains(p, "month"):
return 30 * 24 * time.Hour
}
return 0
}
// zcodeStartQuota is the Start Plan's allowance: a window for each of its
// buckets, a model's tokens for the day or for the plan's time, as ZCode
// shows them.
func zcodeStartQuota(ctx context.Context, l Login, jwt string) SubscriptionQuota {
q := SubscriptionQuota{Provider: "zcode", Name: "ZCode", Icon: "zcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
b, err := zcodeStartBalance(ctx, jwt)
if err != nil {
q.Error = err.Error()
return q
}
name, until, ok := b.active()
if !ok {
q.Error = "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started"
return q
}
q.Plan = name
if until != nil {
q.Until, q.Renew = until, "off"
}
for _, x := range b.Balances {
total, hasTotal := zcodeNum(x.Total)
used, hasUsed := zcodeNum(x.Used)
left, hasLeft := zcodeNum(x.Remaining)
if !hasTotal && !hasUsed && !hasLeft {
continue
}
var models []string
for _, c := range x.Capabilities {
if m := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c), "model:")); m != "" {
models = append(models, m)
}
}
w := QuotaWindow{Name: firstNonEmpty(x.ShowName, strings.Join(models, ", "), "Credits")}
if !hasUsed && hasTotal && hasLeft {
used = total - left
}
if hasTotal && total > 0 {
w.Used = 100 * used / total
w.Display = fmt.Sprintf("%s / %s", compactNumber(used), compactNumber(total))
}
if t, ok := zcodeNum(x.ExpiresAt); ok && t > 0 {
r := time.Unix(int64(t), 0)
w.ResetsAt = &r
}
for _, p := range b.Plans {
if x.UserPlanID != "" && p.UserPlanID != "" && x.UserPlanID != p.UserPlanID || (x.UserPlanID == "" || p.UserPlanID == "") && x.PlanID != p.PlanID {
continue
}
for _, e := range p.Entitlements {
if e.ID == x.Entitlement {
w.Span = zcodePeriod(e.Period)
}
}
}
if w.Span == 0 {
s, ok1 := zcodeNum(x.PeriodStart)
e, ok2 := zcodeNum(x.PeriodEnd)
if ok1 && ok2 && e > s {
w.Span = time.Duration(e-s) * time.Second
}
}
if len(models) > 0 {
w.matches = func(model string) bool {
for _, m := range models {
if strings.EqualFold(m, model) {
return true
}
}
return false
}
}
q.Windows = append(q.Windows, w)
}
return q
}