Files
magpie/internal/provider/zcode.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

1061 lines
33 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.
// A ZCode subscription is Z.ai's GLM Coding Plan, which ZCode (Zhipu's
// desktop app) signs in to. The plan is served on an Anthropic-compatible
// endpoint to a plain API key, `<id>.<secret>`, that ZCode mints for the
// account and names zcode-api-key; magpie uses that key as ZCode does.
//
// ZCode's own account is read, never changed, from its credential store,
// ~/.zcode/v2/credentials.json: each value is "enc:v1:" + iv.tag.ciphertext
// (base64url), AES-256-GCM under sha256 of $ZCODE_CREDENTIAL_SECRET, or
// else of "zcode-credential-fallback:<platform>:<home>:<user>". Further
// accounts are signed in by magpie with ZCode's own polling sign-in
// (zcode.z.ai/api/v1/oauth/cli/…), and their key is kept in logins.json.
//
// An account with no Coding Plan uses ZCode's free Start Plan instead,
// with ZCode's own session token (zcode_start.go); a seat on a team's GLM
// Coding Plan uses the team project's own key (zcode_team.go).
//
// The plan's models are ZCode's, as its built-in config lists them for the
// coding plan (zcode_models.go); zcodeModels are them before that is read.
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/user"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// Where ZCode's coding plan and its sign-in are; vars so tests can point
// them elsewhere.
var (
ZCodeZaiBase = "https://api.z.ai/api/anthropic"
ZCodeBigModelBase = "https://open.bigmodel.cn/api/anthropic"
zcodeAPI = "https://zcode.z.ai"
zcodeZaiAPI = "https://api.z.ai"
zcodeBigModelAPI = "https://bigmodel.cn" // BigModel's business API, where ZCode asks it
)
// zcodeAppVersion is the ZCode whose sign-in magpie makes.
const zcodeAppVersion = "3.14.3"
var zcodeModels = []catalog.Model{
{ID: "GLM-5.3", Name: "GLM-5.3", Context: 1_000_000, Efforts: []string{"low", "high", "max"}},
{ID: "GLM-5.3-Flash", Name: "GLM-5.3-Flash", Context: 1_000_000, Efforts: []string{"low", "high", "max"}},
{ID: "GLM-5.2", Name: "GLM-5.2", Context: 1_000_000, Efforts: []string{"none", "high", "max"}},
{ID: "GLM-5-Turbo", Name: "GLM-5-Turbo", Context: 200_000, Efforts: []string{"none", "high"}},
}
// zcodeKey is a coding plan's key and where it is served.
type zcodeKey struct {
Key string `json:"apiKey,omitempty"`
Base string `json:"base"`
JWT string `json:"jwt,omitempty"` // ZCode's session token, the Start Plan's key (zcode_start.go)
// A seat on a team's plan (zcode_team.go): the team project it is in,
// and the account's business sign-in, as its Authorization, for the
// plan's term and resets. Key is the project's own key, or "" while it
// is still to be found (ZCode's own account).
Token string `json:"token,omitempty"`
Org string `json:"org,omitempty"`
Project string `json:"project,omitempty"`
// UID is the account's user id, as ZCode's sign-in names it: with the
// site, which account this is (zcodeSame).
UID string `json:"uid,omitempty"`
}
// ---- ZCode's own account ------------------------------------------------------
func zcodeCredentialsPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".zcode", "v2", "credentials.json")
}
// zcodeSecret is the key ZCode encrypts its credentials with.
func zcodeSecret() []byte {
seed := os.Getenv("ZCODE_CREDENTIAL_SECRET")
if seed == "" {
home, _ := os.UserHomeDir()
name := ""
if u, err := user.Current(); err == nil {
name = u.Username
if i := strings.LastIndexByte(name, '\\'); i >= 0 {
name = name[i+1:] // DOMAIN\user; node's userInfo() has the user alone
}
}
platform := runtime.GOOS
if platform == "windows" {
platform = "win32"
}
seed = "zcode-credential-fallback:" + platform + ":" + home + ":" + name
}
sum := sha256.Sum256([]byte(seed))
return sum[:]
}
func zcodeDecrypt(secret []byte, v string) (string, bool) {
rest, ok := strings.CutPrefix(v, "enc:v1:")
if !ok {
return "", false
}
parts := strings.Split(rest, ".")
if len(parts) != 3 {
return "", false
}
var raw [3][]byte
for i, p := range parts {
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(p, "="))
if err != nil {
return "", false
}
raw[i] = b
}
block, err := aes.NewCipher(secret)
if err != nil {
return "", false
}
gcm, err := cipher.NewGCMWithNonceSize(block, len(raw[0]))
if err != nil {
return "", false
}
out, err := gcm.Open(nil, raw[0], append(raw[2], raw[1]...), nil)
return string(out), err == nil
}
// zcodeOwn is the account ZCode is signed in to, its coding plan's key and
// ZCode's session token (the Start Plan's key); ok is false when it has
// neither.
func zcodeOwn() (who string, k zcodeKey, ok bool) {
var store map[string]string
if !readJSON(zcodeCredentialsPath(), &store) {
return "", zcodeKey{}, false
}
secret := zcodeSecret()
for name, v := range store {
// account-provider:coding-plan:account:zai-individual-coding-plan:account:<uuid>:api-key
if !strings.Contains(name, ":coding-plan:") || !strings.HasSuffix(name, ":api-key") {
continue
}
key, ok := zcodeDecrypt(secret, v)
if !ok || !strings.Contains(key, ".") {
continue
}
base := ZCodeZaiBase
if strings.Contains(name, ":bigmodel-") {
base = ZCodeBigModelBase
}
// Z.ai's first, as ZCode lists it
if k.Key == "" || (base == ZCodeZaiBase && k.Base != ZCodeZaiBase) {
k = zcodeKey{Key: key, Base: base}
}
}
if v, ok := store["zcodejwttoken"]; ok {
if s, ok := zcodeDecrypt(secret, v); ok {
k.JWT = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "Bearer "))
}
}
// ZCode switched to a team's plan: that project's key, found when asked
if t, ok := zcodeOwnTeam(store, secret); ok {
t.JWT = k.JWT
k = t
}
if k.Key == "" && k.JWT == "" && !k.team() {
return "", zcodeKey{}, false
}
if k.Base == "" {
k.Base = ZCodeZaiBase
}
var info struct {
Email string `json:"email"`
Name string `json:"name"`
ID string `json:"user_id"`
}
for _, name := range []string{"oauth:zai:user_info", "oauth:bigmodel:user_info"} {
if v, ok := store[name]; ok && info.Email+info.Name+info.ID == "" {
if s, ok := zcodeDecrypt(secret, v); ok {
_ = json.Unmarshal([]byte(s), &info)
}
}
}
k.UID = strings.TrimSpace(info.ID)
return zcodeWho(info.Email, info.Name, info.ID), k, true
}
// zcodeWho names a Z.ai account: its email, or the phone number of one
// signed in by phone (its email is then <phone>@phone.local).
func zcodeWho(email, name, id string) string {
email = strings.TrimSuffix(email, "@phone.local")
return firstNonEmpty(email, name, id, "ZCode")
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s = strings.TrimSpace(s); s != "" {
return s
}
}
return ""
}
// ---- the accounts -------------------------------------------------------------
type zcodeLoginKey struct {
Login
key zcodeKey
}
func zcodeSaved(l savedLogin) (zcodeKey, bool) {
var k zcodeKey
if json.Unmarshal(l.Auth, &k) != nil || k.Key == "" && k.JWT == "" && !k.team() {
return zcodeKey{}, false
}
if k.Base == "" {
k.Base = ZCodeZaiBase
}
return k, true
}
// zcodeLogins is every ZCode account signed in, the first in use first.
func zcodeLogins() []zcodeLoginKey {
ownUser, own, hasOwn := zcodeOwn()
if !hasOwn {
ownUser = ""
}
var out []zcodeLoginKey
for _, l := range sideLogins("zcode", ownUser, func(l savedLogin) bool {
_, ok := zcodeSaved(l)
return ok
}) {
k := own
if !l.saved.own() {
k, _ = zcodeSaved(l.saved)
}
out = append(out, zcodeLoginKey{l.Login, k})
}
return out
}
func zcodeSide() []sideLogin {
var out []sideLogin
for _, z := range zcodeLogins() {
out = append(out, sideLogin{Login: z.Login})
}
return out
}
func zcodeLoginList() []Login { return loginsOf(zcodeSide()) }
func switchZCodeLogin(user string) error { return switchSideLogin("zcode", user, zcodeSide()) }
func setZCodeLoginOn(user string, on bool) error {
return setSideLoginOn("zcode", user, on, zcodeSide())
}
func forgetZCodeLogin(user string) error {
return forgetSideLogin("zcode", user, zcodeSide(), nil)
}
func zcodeAccount() (Provider, bool) {
ls := zcodeLogins()
if len(ls) == 0 {
return Provider{}, false
}
return zcodeProvider(ls[0].User, ls[0].Plan, ls[0].key), true
}
// zcodeAlsoOn is the ZCode accounts in use behind the first.
func zcodeAlsoOn() []Provider {
var out []Provider
for _, l := range zcodeLogins() {
if !l.Active && l.On {
out = append(out, zcodeProvider(l.User, l.Plan, l.key))
}
}
return out
}
func zcodeProvider(who, plan string, k zcodeKey) Provider {
acct := &Account{Agent: "zcode", User: who, Plan: plan}
// the plan the account is on: its Coding Plan, or ZCode's Start Plan
// when it has none (zcode_start.go)
base := func(start bool) string {
if start {
return zcodeStartBase()
}
return k.Base
}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
start := zcodeOnStart(ctx, k)
zcodeRebase(req, base(!start), base(start))
key := k.Key
if k.team() && key == "" {
var err error
if key, err = zcodeTeamKeyOf(ctx, k); err != nil {
return err
}
}
if start {
if zcodeJWTExpired(k.JWT) {
return errZCodeExpired
}
zcodeStartRequest(req, k.Base, body)
// the token as a Bearer only, no x-api-key, as a client the
// Start Plan still serves sends it
req.Header.Del("x-api-key")
req.Header.Set("Authorization", "Bearer "+k.JWT)
return nil
}
req.Header.Del("Authorization")
req.Header.Set("x-api-key", key)
req.Header.Set("Authorization", "Bearer "+key)
return nil
}
acct.clientFor = zcodeStartClientFor
acct.explain = func(status int, body []byte) string {
if zcodeOnStartAs(nil, k, plan) {
return zcodeStartExplain(status, body)
}
return ""
}
acct.models = func() []catalog.Model {
if zcodeOnStartAs(nil, k, plan) {
return zcodeStartModels
}
return zcodeModels
}
// on the Start Plan, a model only the Coding Plan has (GLM-5.3) is
// neither listed nor picked, though the list fetched last (another
// account's, or this one's before its plan was found) has it
startServes := sync.OnceValue(zcodeStartServes)
acct.unusable = func(model string) bool {
return zcodeOnStartAs(nil, k, plan) && !startServes()(model)
}
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
b := base(zcodeOnStart(ctx, k))
ms, err := zcodeFetchModels(ctx, b)
if err != nil {
return nil, err
}
return ms, catalog.SaveLive("zcode", b, ms)
}
return Provider{ID: "zcode", Name: "ZCode", Icon: "zcode", Anthropic: base(zcodeOnStartAs(nil, k, plan)), Website: "https://zcode.z.ai", Account: acct}
}
// ---- allowance ----------------------------------------------------------------
// zcodeQuota is a coding plan's allowance: credits per five hours and per
// week, as ZCode shows them.
func zcodeQuota(ctx context.Context, l Login, k zcodeKey) SubscriptionQuota {
if k.team() { // a seat on a team's plan
return zcodeTeamQuota(ctx, l, k)
}
if zcodeOnStart(ctx, k) { // no Coding Plan: ZCode's Start Plan
return zcodeStartQuota(ctx, l, k.JWT)
}
q := SubscriptionQuota{Provider: "zcode", Name: "ZCode", Icon: "zcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
var data zhipuLimits
if err := zcodeGet(ctx, zcodeRoot(k.Base)+"/api/monitor/usage/quota/limit", k.Key, &data); err != nil {
q.Error = err.Error()
return q
}
if data.Level != "" {
q.Plan = "GLM Coding " + strings.ToUpper(data.Level[:1]) + data.Level[1:]
}
q.Windows = data.windows()
q.Until, q.Renew = zhipuTerm(ctx, zcodeRoot(k.Base), k.Key)
return q
}
// zhipuLimits is what /api/monitor/usage/quota/limit tells: a plan's
// limits, TOKENS_LIMIT on a person's plan and CREDIT_LIMIT on a team's,
// unit 3 the five hours (a team's come with no number) and 6 the week.
type zhipuLimits struct {
Limits []struct {
Type string `json:"type"`
Unit int `json:"unit"`
Number int `json:"number"`
Usage *float64 `json:"usage"`
Current *float64 `json:"currentValue"`
Remaining *float64 `json:"remaining"`
Percent *float64 `json:"percentage"`
Reset int64 `json:"nextResetTime"`
} `json:"limits"`
Level string `json:"level"`
}
// windows are the limits as windows: what is used of the whole when both
// are told (the whole less what remains, or the current value), else the
// percentage the vendor gives. TIME_LIMIT is the month's MCP tool calls,
// which ZCode shows but never stops the models on, so it is set aside, as
// is a limit whose whole is told as 0: no cap (an older plan's), which
// the vendor may still give as 100% used.
func (d zhipuLimits) windows() []QuotaWindow {
out := []QuotaWindow{}
for _, x := range d.Limits {
span := zcodeSpan(x.Unit, x.Number)
w := QuotaWindow{Name: zcodeWindowName(span), Span: span}
if strings.EqualFold(x.Type, "TIME_LIMIT") {
w.Name, w.Aside = "MCP · Month", true
}
if x.Percent != nil {
w.Used = *x.Percent
}
if x.Usage != nil && *x.Usage == 0 {
w.Used, w.Aside = 0, true
}
if x.Usage != nil && *x.Usage > 0 {
total := *x.Usage
switch {
case x.Remaining != nil:
used := total - *x.Remaining
w.Used = 100 * used / total
w.Display = fmt.Sprintf("%s / %s", compactNumber(used), compactNumber(total))
case x.Current != nil:
if x.Percent == nil {
w.Used = 100 * *x.Current / total
}
w.Display = fmt.Sprintf("%s / %s", compactNumber(*x.Current), compactNumber(total))
}
}
if x.Reset > 0 {
t := time.UnixMilli(x.Reset)
w.ResetsAt = &t
}
out = append(out, w)
}
return out
}
// zhipuTerm is how long a GLM Coding plan is paid for, from the
// account's subscriptions as ZCode reads them: the valid one's next
// renewal, a charge when it renews itself, else the end of its time — or
// the last date its "valid" span names.
func zhipuTerm(ctx context.Context, root, key string) (*time.Time, string) {
var subs []zhipuSubscription
if zcodeGet(ctx, root+"/api/biz/subscription/list", key, &subs) != nil {
return nil, ""
}
return zhipuTermOf(subs)
}
type zhipuSubscription struct {
Status string `json:"status"`
Valid string `json:"valid"`
AutoRenew any `json:"autoRenew"` // true or 1
NextRenew string `json:"nextRenewTime"`
}
// zhipuDate finds the dates in a span like "2026-09-18 12:00:00-2026-10-18 12:00:00".
var zhipuDate = regexp.MustCompile(`\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?`)
// zhipuTime reads the plan's times, "2026-10-18 12:00:00" in Beijing.
func zhipuTime(s string) *time.Time {
s = strings.Replace(strings.TrimSpace(s), " ", "T", 1)
cst := time.FixedZone("CST", 8*3600)
for _, f := range []string{"2006-01-02T15:04:05", "2006-01-02"} {
if t, err := time.ParseInLocation(f, s, cst); err == nil {
return &t
}
}
return nil
}
func zhipuTermOf(subs []zhipuSubscription) (*time.Time, string) {
for _, s := range subs {
if !strings.EqualFold(s.Status, "VALID") {
continue
}
auto := s.AutoRenew == true || s.AutoRenew == float64(1)
if t := zhipuTime(s.NextRenew); t != nil {
if auto {
return t, "auto"
}
return t, "off"
}
if ds := zhipuDate.FindAllString(s.Valid, -1); len(ds) > 0 && !auto {
if t := zhipuTime(ds[len(ds)-1]); t != nil {
return t, "off"
}
}
return nil, ""
}
return nil, ""
}
// zcodeSpan reads a limit's window: unit 3 counts hours, 6 weeks (and
// 4, 5 days and months by the same count). A team's five hours come with
// no count.
func zcodeSpan(unit, n int) time.Duration {
if unit == 3 && n <= 0 {
n = 5
}
n = max(n, 1)
switch unit {
case 1:
return time.Duration(n) * time.Minute
case 3:
return time.Duration(n) * time.Hour
case 4:
return time.Duration(n) * 24 * time.Hour
case 5:
return time.Duration(n) * 30 * 24 * time.Hour
case 6:
return time.Duration(n) * 7 * 24 * time.Hour
}
return 0
}
func zcodeWindowName(span time.Duration) string {
switch {
case span == 0:
return "Credits"
case span < 24*time.Hour:
return fmt.Sprintf("%d hours", int(span.Hours()))
case span == 7*24*time.Hour:
return "Weekly"
case span >= 28*24*time.Hour:
return "Monthly"
}
return fmt.Sprintf("%d days", int(span.Hours()/24))
}
// zcodeRoot is the site a plan's endpoint is on: https://api.z.ai.
func zcodeRoot(base string) string {
if u, err := url.Parse(base); err == nil && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return zcodeZaiAPI
}
func zcodeLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
for _, z := range zcodeLogins() {
if strings.EqualFold(z.User, l.User) {
return zcodeQuota(ctx, l, z.key)
}
}
return SubscriptionQuota{Provider: "zcode", Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
// zcodePlan names the coding plan a key has, "" for none.
func zcodePlan(ctx context.Context, k zcodeKey) (string, error) {
var subs []struct {
Product string `json:"productName"`
Status string `json:"status"`
}
if err := zcodeGet(ctx, zcodeRoot(k.Base)+"/api/biz/subscription/list", k.Key, &subs); err != nil {
return "", err
}
for _, s := range subs {
if strings.EqualFold(s.Status, "VALID") {
return s.Product, nil
}
}
return "", nil
}
// ---- Z.ai's API ---------------------------------------------------------------
// zcodeCall asks one of Z.ai's JSON endpoints, which wrap what they say in
// {code, msg, data}: code 0 or 200 is a success.
func zcodeCall(ctx context.Context, method, u, auth string, body, dst any) error {
return zcodeCallH(ctx, method, u, auth, nil, body, dst)
}
// zcodeCallH is zcodeCall with more headers: a team's organization and
// project.
func zcodeCallH(ctx context.Context, method, u, auth string, hdr map[string]string, body, dst any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "ZCode/"+zcodeAppVersion)
zcodeDeviceHeader(req)
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
var env struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
if json.Unmarshal(b, &env) == nil && env.Msg != "" {
if c := strings.Trim(string(env.Code), `"`); c != "" && c != "null" && c != "0" {
return fmt.Errorf("%s (%d, code %s)", env.Msg, res.StatusCode, c)
}
return fmt.Errorf("%s (%d)", env.Msg, res.StatusCode)
}
return &accountStatusError{status: res.StatusCode}
}
if err := json.Unmarshal(b, &env); err != nil {
return err
}
if c := strings.Trim(string(env.Code), `"`); c != "" && c != "0" && c != "200" && c != "null" {
return errors.New(firstNonEmpty(env.Msg, "error "+c))
}
if dst == nil || len(env.Data) == 0 || string(env.Data) == "null" {
return nil
}
return json.Unmarshal(env.Data, dst)
}
// zcodeGet asks with a coding plan's key, which goes bare in Authorization.
func zcodeGet(ctx context.Context, u, key string, dst any) error {
return zcodeCall(ctx, http.MethodGet, u, key, nil, dst)
}
// zcodeSite is the site a key's account is on: "bigmodel" or "zai".
func zcodeSite(k zcodeKey) string {
if k.Base == ZCodeBigModelBase || strings.HasSuffix(hostOf(k.Base), "bigmodel.cn") {
return "bigmodel"
}
return "zai"
}
// zcodeSame says two keys are the same account: on the same site, with the
// same user id; one kept before magpie kept ids is told by its key's id,
// and taken for the same when it has none, as it was before.
func zcodeSame(a, b zcodeKey) bool {
if zcodeSite(a) != zcodeSite(b) {
return false
}
if a.UID != "" && b.UID != "" {
return a.UID == b.UID
}
ia, _, _ := strings.Cut(a.Key, ".")
ib, _, _ := strings.Cut(b.Key, ".")
return ia == "" || ib == "" || ia == ib
}
// zcodeName is the name an account signed in is kept under: who, as ZCode
// names it, unless another account is kept under that name, then who and
// its site, numbered when that is taken too. Accounts are kept by name, so
// a Z.ai and a BigModel account on the same phone number, or two ZCode
// names alike, took each other's place (Bandit on Discord). The same
// account signed in again keeps its name and is updated in place.
func zcodeName(who, site string, k zcodeKey, have []zcodeLoginKey) string {
if k.UID != "" {
for _, l := range have {
if l.key.UID == k.UID && zcodeSite(l.key) == site {
return l.User
}
}
}
for n := 1; ; n++ {
name := who
if n == 2 {
name = who + " (" + zcodeSiteName(site) + ")"
} else if n > 2 {
name = fmt.Sprintf("%s (%s %d)", who, zcodeSiteName(site), n-1)
}
i := slices.IndexFunc(have, func(l zcodeLoginKey) bool { return strings.EqualFold(l.User, name) })
if i < 0 || zcodeSame(have[i].key, k) {
return name
}
}
}
// ---- signing in ---------------------------------------------------------------
// startZCodeSignIn is ZCode's own polling sign-in: zcode.z.ai opens a flow,
// the browser signs in to Z.ai — or to BigModel (智谱, bigmodel.cn) when
// site is "bigmodel" — and the flow is asked until it is ready. The
// account's coding plan key is then found or made, as ZCode does it.
func startZCodeSignIn(s *signInFlow, site string) error {
if site != "bigmodel" {
site = "zai"
}
ctx, cancel := context.WithCancel(context.Background())
b := make([]byte, 32)
_, _ = rand.Read(b)
poll := "Bearer " + hex.EncodeToString(b)
var flow struct {
ID string `json:"flow_id"`
URL string `json:"authorize_url"`
Expires float64 `json:"expires_at"`
Interval float64 `json:"poll_interval_sec"`
}
if err := zcodeCall(ctx, http.MethodPost, zcodeAPI+"/api/v1/oauth/cli/init", poll, map[string]string{"provider": site}, &flow); err != nil {
cancel()
return fmt.Errorf("ZCode sign-in: %w", err)
}
u, err := url.Parse(flow.URL)
if flow.ID == "" || err != nil || u.Scheme != "https" {
cancel()
return errors.New("ZCode gave no sign-in page")
}
// where Z.ai or BigModel sends the browser back, as ZCode sets it
// (BigModel's page names it redirect)
back := zcodeAPI + "/app/oauth/login?" + url.Values{"redirect": {"zcode://oauth/callback"}, "app_version": {zcodeAppVersion}}.Encode()
q := u.Query()
if site == "bigmodel" {
q.Set("redirect", back)
} else {
q.Set("redirect_uri", back)
}
u.RawQuery = q.Encode()
s.mu.Lock()
s.st.URL = u.String()
s.stop = cancel
s.mu.Unlock()
interval := time.Duration(max(flow.Interval, 1)) * time.Second
deadline := time.Unix(int64(flow.Expires), 0)
if flow.Expires == 0 {
deadline = time.Now().Add(5 * time.Minute)
}
go func() {
defer cancel()
fail := func(msg string) { s.finish(SignInState{State: "failed", Error: msg}) }
for {
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
if time.Now().After(deadline) {
fail("the sign-in expired; start again")
return
}
var got struct {
Status string `json:"status"`
Token string `json:"token"` // ZCode's own session: the Start Plan's key
Zai struct {
AccessToken string `json:"access_token"`
} `json:"zai"`
BigModel struct {
AccessToken string `json:"access_token"`
Camel string `json:"accessToken"`
} `json:"bigmodel"`
User struct {
ID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
}
err := zcodeCall(ctx, http.MethodGet, zcodeAPI+"/api/v1/oauth/cli/poll/"+url.PathEscape(flow.ID), poll, nil, &got)
token := strings.TrimSpace(got.Zai.AccessToken)
if site == "bigmodel" {
token = firstNonEmpty(got.BigModel.AccessToken, got.BigModel.Camel)
}
switch {
case ctx.Err() != nil:
return
case err != nil:
var st *accountStatusError
if errors.As(err, &st) && st.status >= 400 && st.status < 500 && st.status != 408 && st.status != 429 {
fail("ZCode sign-in: " + err.Error())
return
}
continue // a hiccup: ask again
case got.Status == "pending" || got.Status == "":
continue
case got.Status == "failed":
fail("the sign-in was declined on " + zcodeSiteName(site))
return
case got.Status != "ready" || token == "":
fail("ZCode sign-in: unexpected answer " + got.Status)
return
}
k, plan, err := zcodeSignedIn(ctx, site, token, strings.TrimSpace(got.Token))
if err != nil {
fail(err.Error())
return
}
k.UID = strings.TrimSpace(got.User.ID)
ownUser, own, ok := zcodeOwn()
if !ok {
ownUser = ""
}
have := zcodeLogins()
if ok && !slices.ContainsFunc(have, func(l zcodeLoginKey) bool { return strings.EqualFold(l.User, ownUser) }) {
have = append(have, zcodeLoginKey{Login{User: ownUser}, own}) // removed in magpie, still ZCode's
}
who := zcodeName(zcodeWho(got.User.Email, got.User.Name, got.User.ID), site, k, have)
auth, _ := json.Marshal(k)
if err := addSideLogin(savedLogin{Agent: "zcode", User: who, Plan: plan, Auth: auth}, ownUser, func(savedLogin) {}); err != nil {
fail(err.Error())
return
}
s.finish(SignInState{State: "done", User: who, Plan: plan, Using: ok && strings.EqualFold(ownUser, who)})
return
}
}()
return nil
}
// zcodeSignedIn is what a sign-in gives magpie: the account's coding plan
// key and plan; or, with none of its own, a seat on a team's plan
// (zcode_team.go); or else ZCode's token for its Start Plan while the
// account has one. site is "zai" or "bigmodel", where it signed in.
func zcodeSignedIn(ctx context.Context, site, token, jwt string) (zcodeKey, string, error) {
var (
k zcodeKey
plan string
err error
team zcodeTeamRefusal
)
root := zcodeSiteAPI(site)
auth, err := zcodeBizAuth(ctx, site, token)
if err == nil {
var info zcodeCustomer
if info, err = zcodeCustomerInfo(ctx, root, auth, site); err == nil {
k, plan, err = zcodeMintKey(ctx, site, root, auth, info)
if err == nil && plan != "" {
k.JWT = jwt
return k, plan, nil
}
// none of its own: a team's, as ZCode lists them
t, tplan, why, terr := zcodeTeamSignIn(ctx, site, root, auth, info)
if terr == nil {
t.JWT = jwt
return t, tplan, nil
}
team = why
if why == "" && len(info.teamProjects()) > 0 {
team = zcodeTeamRefusal(terr.Error())
}
}
}
// the Start Plan's balance not read is said as it is, not taken for
// the account having none (#282: a 400 "parameter error" read as that)
var berr error
if jwt != "" {
var b zcodeBalance
if b, berr = zcodeStartBalance(ctx, jwt); berr == nil {
if name, _, ok := b.active(); ok {
if err != nil || k.Key == "" { // no key made: the Start Plan alone
k = zcodeKey{Base: zcodeSiteBase(site)}
}
k.JWT = jwt
return k, name, nil
}
}
}
switch {
case team != "" && berr != nil:
return zcodeKey{}, "", fmt.Errorf("%s; ZCode's Start Plan: %v", team, berr)
case team != "":
return zcodeKey{}, "", errors.New(string(team))
case err != nil && berr != nil:
return zcodeKey{}, "", fmt.Errorf("%w; ZCode's Start Plan: %v", err, berr)
case err != nil:
return zcodeKey{}, "", err
case berr != nil:
return zcodeKey{}, "", fmt.Errorf("this %s account has no GLM Coding Plan, of its own or a team's, and ZCode's Start Plan could not be read: %v", zcodeSiteName(site), berr)
}
return zcodeKey{}, "", fmt.Errorf("this %s account has no GLM Coding Plan, of its own or a team's, and ZCode's Start Plan has ended or was never started — subscribe at %s, then add it again", zcodeSiteName(site), zcodeSubscribeAt(site))
}
// zcodeSiteName, zcodeSiteAPI, zcodeSiteBase and zcodeSubscribeAt are a
// sign-in's site: Z.ai, or BigModel (智谱), whose business API ZCode asks
// at bigmodel.cn and whose plan is served at open.bigmodel.cn.
func zcodeSiteName(site string) string {
if site == "bigmodel" {
return "BigModel"
}
return "Z.ai"
}
func zcodeSiteAPI(site string) string {
if site == "bigmodel" {
return zcodeBigModelAPI
}
return zcodeZaiAPI
}
func zcodeSiteBase(site string) string {
if site == "bigmodel" {
return ZCodeBigModelBase
}
return ZCodeZaiBase
}
func zcodeSubscribeAt(site string) string {
if site == "bigmodel" {
return "bigmodel.cn/glm-coding"
}
return "z.ai/subscribe"
}
// zcodeBizAuth is the Authorization a sign-in gives the business API: for
// Z.ai its token exchanged for a business one, as a Bearer; BigModel's
// token is that already, and goes bare (createBigModelBizHeaders).
func zcodeBizAuth(ctx context.Context, site, token string) (string, error) {
if site == "bigmodel" {
return token, nil
}
var biz struct {
Token string `json:"access_token"`
}
if err := zcodeCall(ctx, http.MethodPost, zcodeZaiAPI+"/api/auth/z/login", "", map[string]string{"token": token}, &biz); err != nil || biz.Token == "" {
return "", fmt.Errorf("Z.ai sign-in: %v", firstErr(err, "no token"))
}
return "Bearer " + biz.Token, nil
}
// zcodeCustomer is an account's organizations and their projects; a
// project of type 2 is a team's coding plan (isBigModelTeamCodingPlanProject).
type zcodeCustomer struct {
Orgs []struct {
ID string `json:"organizationId"`
Name string `json:"organizationName"`
Projects []struct {
ID string `json:"projectId"`
Name string `json:"projectName"`
Type any `json:"projectType"`
} `json:"projects"`
} `json:"organizations"`
}
func zcodeCustomerInfo(ctx context.Context, root, auth, site string) (zcodeCustomer, error) {
var info zcodeCustomer
if err := zcodeCall(ctx, http.MethodGet, root+"/api/biz/customer/getCustomerInfo", auth, nil, &info); err != nil {
return info, fmt.Errorf("%s account: %w", zcodeSiteName(site), err)
}
return info, nil
}
// zcodeMintKey is the account's own coding plan key: the key named
// zcode-api-key in its default project, made if it isn't there, with its
// secret; and the plan it has, "" for none.
func zcodeMintKey(ctx context.Context, site, root, auth string, info zcodeCustomer) (zcodeKey, string, error) {
// the default organization and project, as ZCode picks them
org, proj := "", ""
for _, o := range info.Orgs {
var ps []string
def := ""
for _, p := range o.Projects {
if p.ID == "" || fmt.Sprint(p.Type) == "2" {
continue
}
ps = append(ps, p.ID)
if def == "" && strings.Contains(p.Name, "默认项目") {
def = p.ID
}
}
if o.ID == "" || len(ps) == 0 {
continue
}
if def == "" {
def = ps[0]
}
if org == "" || strings.Contains(o.Name, "默认机构") {
org, proj = o.ID, def
if strings.Contains(o.Name, "默认机构") {
break
}
}
}
if org == "" {
return zcodeKey{}, "", fmt.Errorf("this %s account has no project for an API key", zcodeSiteName(site))
}
keys := root + "/api/biz/v1/organization/" + url.PathEscape(org) + "/projects/" + url.PathEscape(proj) + "/api_keys"
key, err := zcodeProjectKey(ctx, site, keys, auth, nil, map[string]any{"name": "zcode-api-key"})
if err != nil {
return zcodeKey{}, "", err
}
k := zcodeKey{Key: key, Base: zcodeSiteBase(site)}
plan, err := zcodePlan(ctx, k)
if err != nil {
return zcodeKey{}, "", fmt.Errorf("GLM Coding Plan: %w", err)
}
return k, plan, nil // plan "" when it has none: zcodeSignedIn looks further
}
// zcodeProjectKey finds the key a project has under want's name (and
// keyType, when want has one), makes it if it isn't there, and reads its
// secret: `<id>.<secret>`.
func zcodeProjectKey(ctx context.Context, site, keys, auth string, hdr map[string]string, want map[string]any) (string, error) {
type apiKey struct {
Name string `json:"name"`
APIKey string `json:"apiKey"`
KeyType any `json:"keyType"`
}
name := fmt.Sprint(want["name"])
kind, typed := want["keyType"]
var list []apiKey
if err := zcodeCallH(ctx, http.MethodGet, keys, auth, hdr, nil, &list); err != nil {
return "", fmt.Errorf("%s API keys: %w", zcodeSiteName(site), err)
}
id := ""
for _, k := range list {
if k.Name == name && (!typed || fmt.Sprint(k.KeyType) == fmt.Sprint(kind)) && strings.TrimSpace(k.APIKey) != "" {
id = strings.TrimSpace(k.APIKey)
}
}
if id == "" {
var made apiKey
if err := zcodeCallH(ctx, http.MethodPost, keys, auth, hdr, want, &made); err != nil {
return "", fmt.Errorf("%s API key: %w", zcodeSiteName(site), err)
}
id = strings.TrimSpace(made.APIKey)
}
var secret struct {
Secret string `json:"secretKey"`
}
if id != "" {
if err := zcodeCallH(ctx, http.MethodGet, keys+"/copy/"+url.PathEscape(id), auth, hdr, nil, &secret); err != nil {
return "", fmt.Errorf("%s API key: %w", zcodeSiteName(site), err)
}
}
if id != "" && strings.TrimSpace(secret.Secret) == "" && typed {
return id, nil // a team's key with no secret goes as it is (copyBigModelTeamPlanProjectApiKeySecret)
}
if id == "" || strings.TrimSpace(secret.Secret) == "" {
return "", fmt.Errorf("%s gave no API key", zcodeSiteName(site))
}
return id + "." + strings.TrimSpace(secret.Secret), nil
}
func firstErr(err error, otherwise string) string {
if err != nil {
return err.Error()
}
return otherwise
}