Files
magpie/internal/provider/zcode_start.go
T
yetone 96446806bd zcode: a Start Plan account neither lists nor offers GLM-5.3, and the move to the plugin doesn't count it lost (悠悠哥 on Discord: "仍使用内置:插件不提供 GLM-5.3" moving ZCode onto its plugin) — GLM-5.3 is the Coding Plan's alone (ZCode's builtin config lists GLM-5.3-Flash, GLM-5.2 and GLM-5-Turbo for account:zai-start-plan), and the plugin serves a Start Plan account what ZCode does. But a Z.ai account with a key made and no Coding Plan bought, whose requests go to the Start Plan, was taken for a Coding Plan account until a request found its route (zcodeOnStart with no context said the Coding Plan), and afterwards was listed whatever list was fetched last (the Coding Plan's, another account's or its own before), so GLM-5.3 was listed, could be ticked, and the move found the plugin "short" of it. Now: before a route is found, the plan the account showed at sign-in says it (zcodeOnStartAs, zcodeStartNamed: "Start Plan"); on the Start Plan, a model the plan doesn't serve (zcodeStartServes: ZCode's config on the machine, else zcodeStartModels) is unusable, taken out of a fetched list and out of the picks, with a list fetched or not (Exposed); and a mover may say which models the built-in served on an account (mover.builtin), ZCode's saying a Start Plan account was never served GLM-5.3, so one the built-in didn't serve either is no loss. A model really lost is named with the account and its plan: "the plugin doesn't serve GLM-5.3 for a@x (GLM Coding Pro), though the built-in does" (插件没有为 … 提供 …(内置提供)), the why's args carrying user. TestZCodeStartPlanHasNoGLM53 (a Start Plan account before and after the Coding Plan's list was fetched, GLM-5.3 ticked or not; a Coding Plan account still has it; the mover's builtin) fails without the change (it listed GLM-5.3 GLM-5.3-Flash GLM-5.2 GLM-5-Turbo); TestMoveToPlugin's new case (a model the built-in didn't serve moves; one it did is named with "a@fake (Fake Start)"); plugin-move-unserved.test.cjs (WebKit, en and zh, with an account and without) fails without the app.js change and passes with it, plugin-move.test.cjs passes. No ZCode request or sign-in was made. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass.
2026-10-02 22:00:34 +08:00

546 lines
18 KiB
Go

package provider
// PLUGIN-SERVED (see AGENTS.md): ZCode ("zcode") is a deprecated built-in
// subscription served by its plugin, @magpie-community/opencode-zcode-auth,
// once moved onto it (provider.Moved; the default for a new sign-in). A
// moved one's sign-ins, models, requests and usage are all the plugin's,
// never this code's (only the move, in migrate*.go, still reads its
// accounts). A fix here alone doesn't reach those users; fix the plugin
// (github.com/magpie-community/plugins, packages/zcode) and raise the
// mover's min in internal/provider/migrate_zcode.go.
// 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 (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/netproxy"
)
// 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
}()
// zcodeStartNamed is whether a plan's name, as an account showed it, is
// ZCode's Start Plan (zcodeBalance.active's name for it).
func zcodeStartNamed(plan string) bool {
p := strings.ToLower(plan)
return strings.Contains(p, "start plan") || strings.Contains(p, "start-plan") || strings.Contains(plan, "体验")
}
// zcodeStartServes is the Start Plan's models, as ZCode's config on this
// machine lists them for account:zai-start-plan (zcodeStartModels without
// one): whether it serves model. GLM-5.3 is the Coding Plan's alone.
func zcodeStartServes() func(model string) bool {
ms := zcodeStartModels
if b, ok := zcodeLocalBuiltin(); ok {
if l := b.models("account:zai-start-plan"); len(l) > 0 {
ms = l
}
}
return func(model string) bool {
return slices.ContainsFunc(ms, func(m catalog.Model) bool { return strings.EqualFold(m.ID, model) })
}
}
// 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, so magpie sends it as the app does (zcode_client.go).
// One turned away still is Alibaba Cloud's firewall blocking the address
// (BlockedHint), or ZCode checking for something magpie doesn't send.
const ZCodeStartBlockedHint = "ZCode's Start Plan still turned this request away, though magpie sends it as the ZCode app does; it can be a network block of this IP, or ZCode checking for something new. 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 { return zcodeOnStartAs(ctx, k, "") }
// zcodeOnStartAs is zcodeOnStart for an account that showed plan: with
// ctx nil and nothing found yet, the plan's name says it. An account with
// a key and no Coding Plan (the key made, the plan not bought) is on the
// Start Plan, and was shown the Coding Plan's models, GLM-5.3 among them,
// until a request found where it goes.
func zcodeOnStartAs(ctx context.Context, k zcodeKey, plan string) 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 {
return zcodeStartNamed(plan)
}
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 the plugin the Start Plan serves sends
// with a model request (zcode_client.go): ZCode's app version and SDK,
// the CLI's title, its agent and release, the machine (X-Os-Version being
// "<platform> <release> <arch>", as the environment section has it), the
// language and time zone, a new request and trace id, and no query or
// session id, X-Device-Mid or anthropic-beta.
func zcodeSourceHeaders(req *http.Request) {
platform := zcodePlatform()
category := map[string]string{"darwin": "macos", "win32": "windows"}[platform]
if category == "" {
category = "linux"
}
req.Header.Del("anthropic-beta")
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("User-Agent", "ZCode/"+zcodeAppVersion+" ai-sdk/anthropic/3.0.81")
req.Header.Set("X-ZCode-App-Version", zcodeAppVersion)
req.Header.Set("X-Title", "Z Code@cli")
req.Header.Set("X-ZCode-Agent", "glm")
req.Header.Set("HTTP-Referer", "https://zcode.z.ai") // as the plugin has it, wherever zcodeAPI points
req.Header.Set("X-Platform", platform+"-"+zcodeArch())
req.Header.Set("X-Os-Category", category)
req.Header.Set("X-Os-Version", zcodeOSVersion())
req.Header.Set("X-Release-Channel", "production")
req.Header.Set("X-Client-Language", zcodeLanguage())
req.Header.Set("X-Client-Timezone", zcodeTimezone())
req.Header.Set("X-Request-Id", randomUUID())
req.Header.Set("X-ZCode-Session-Type", "main")
req.Header.Set("X-ZCode-Trace-Id", randomUUID())
}
// zcodeLanguage is the user's locale as Node's Intl gives it (zh-CN,
// en-US): the environment's, else the system's (zcodeSystemLocale), else
// en-US, ICU's own default.
func zcodeLanguage() string {
for _, k := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
if l := zcodeBCP47(os.Getenv(k)); l != "" {
return l
}
}
if l := zcodeBCP47(zcodeSystemLocale()); l != "" {
return l
}
return "en-US"
}
// zcodeBCP47 turns a POSIX locale (zh_CN.UTF-8, en_US@euro) into a BCP 47
// tag (zh-CN), "" for none or C/POSIX.
func zcodeBCP47(v string) string {
v, _, _ = strings.Cut(v, ".")
v, _, _ = strings.Cut(v, "@")
v = strings.TrimSpace(v)
if v == "" || v == "C" || v == "POSIX" {
return ""
}
return strings.ReplaceAll(v, "_", "-")
}
// zcodeTimezone is the IANA name of the local time zone, as Node's Intl
// gives it: TZ, the zone /etc/localtime links to, else one at the local
// offset (Asia/Shanghai at +8, Etc/GMT∓n otherwise, UTC at 0).
func zcodeTimezone() string {
if tz := os.Getenv("TZ"); tz != "" && !strings.HasPrefix(tz, ":") {
return tz
}
if l, err := os.Readlink("/etc/localtime"); err == nil {
if _, name, ok := strings.Cut(l, "zoneinfo/"); ok {
return name
}
}
if name := time.Local.String(); name != "Local" && name != "" && strings.Contains(name, "/") {
return name
}
_, off := time.Now().Zone()
switch {
case off == 8*3600:
return "Asia/Shanghai"
case off == 0:
return "UTC"
case off%3600 == 0 && off > 0:
return "Etc/GMT-" + strconv.Itoa(off/3600)
case off%3600 == 0:
return "Etc/GMT+" + strconv.Itoa(-off/3600)
}
return "UTC"
}
// zcodeStartRequest makes req, a request to the Start Plan, ZCode's own:
// its headers, and a model request's body shaped as ZCode's
// (zcodeStartBody) for an account on base.
func zcodeStartRequest(req *http.Request, base string, body []byte) {
zcodeSourceHeaders(req)
if len(body) == 0 || !strings.HasSuffix(req.URL.Path, "/v1/messages") {
return
}
nb := zcodeStartBody(body, zcodeStartProvider(base), time.Now())
req.Body = io.NopCloser(bytes.NewReader(nb))
req.ContentLength = int64(len(nb))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(nb)), nil }
}
// zcodeStartTransport carries the Start Plan's model requests over
// HTTP/1.1 only. The gateway's client speaks HTTP/2 wherever the server
// offers it, and zcode.z.ai does; every client the Start Plan is known to
// serve speaks HTTP/1.1 to it: ZCode itself (Node's fetch), an OpenCode
// plugin run in magpie's plugin host (Bun's fetch: ARNO's "Freeflow",
// provider zcode-start, served on the same machine and account where
// magpie's own request, its headers and body the same, was turned away
// with 405 / code 3012) and zcode2api-plus (Go, a transport of its own
// with HTTP/2 off). Otherwise as the gateway's: the proxy in force for
// the request (netproxy), and as long a wait for the answer's head.
var zcodeStartTransport = func() *http.Transport {
var h1 http.Protocols
h1.SetHTTP1(true)
return &http.Transport{
Proxy: netproxy.Func,
ResponseHeaderTimeout: 10 * time.Minute,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
Protocols: &h1,
}
}()
var zcodeStartClient = &http.Client{Transport: netproxy.Dispatch(zcodeStartTransport)}
// zcodeStartClientFor is zcodeStartClient for a request to the Start
// Plan's endpoint (where sign put it), nil for any other.
func zcodeStartClientFor(req *http.Request) *http.Client {
if strings.HasPrefix(req.URL.String(), zcodeStartBase()+"/") {
return zcodeStartClient
}
return nil
}
// 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
}