Files
magpie/internal/provider/zcode.go
T
yetone 8117d6f4a9 zcode: a seat on a team's GLM Coding Plan (团队套餐) is signed in, routed and metered as ZCode does it, on BigModel (智谱) as well as Z.ai, and a Zhipu key on a team plan shows its 5-hour and weekly windows and resets (#236 EZN7L2C3: a BigModel team GLM Coding Plan, the sign-in refused it and the usage was never read) — magpie's ZCode sign-in only went to Z.ai and only looked for the account's own coding-plan key, so a member of a team's plan, whose seat lives in a team project, was refused, and a key on a team plan read nothing from /api/monitor/usage/quota/limit. Read from ZCode.app 3.14.3 (host/index.js and config/provider/zcode-builtin.json): the team rules account:{zai|bigmodel}-team-coding-plan use the individual plans' base URLs with no extra inference headers; getCustomerInfo lists the projects, projectType "2" being a team's (isBigModelTeamCodingPlanProject); fetchTeamCodingPlanEntitlement asks /api/biz/team/subscribe/product/querySubscribeDetail (EFFECTIVE + memberGrantStatus VALID usable, UNASSIGNED no seat, EXPIRED run out); ensureBigModelTeamPlanProjectApiKeyWithStatus / copyBigModelTeamPlanProjectApiKeySecret find or create "zcode-team-api-key" (keyType 2) in the team project and copy its secret; createBigModelBizHeaders sends the access token bare in Authorization with bigmodel-organization / bigmodel-project; resolveAccountTeamPlanRuntimeApiKey takes the stored oauth:{family}:access_token (skipping BigModel's when it equals zcodejwttoken); buildQuotaLimitUrl adds type=2 and createBigModelUsageHeaders sends the key with the org/project headers; ZCode keeps the chosen team in ~/.zcode/v2/setting.json providerFamilyConnectionSelections (legacy modelProviderFamilySelectedKeys "team-plan:builtin:…", migrateLegacyAccountConnectionSettings); pollPendingOAuth/startOAuthWithPolling init with {provider:"zai"|"bigmodel"}, BigModel's redirect param being redirect, its token the poll's bigmodel.access_token. Now: the sign-in first asks where the account is — Z.ai or BigModel (智谱) (POST /api/signin takes a site, StartSignInAt, "zcode:bigmodel" too) — and on its site tries the account's own plan, then a team project with a usable seat (a new zcode_team.go: zcodeTeamSignIn keeps the team key, org, project and the biz token, the plan named by its product, "GLM Coding Team" otherwise), then the Start Plan; a team seat not yet granted or a team plan expired is refused saying so ("…has no seat on it yet — ask the team's admin…"), and an account with none of them names both plans and the site's subscribe page. ZCode's own team login is read from its credentials and setting.json (zcodeOwnTeam), its key minted lazily and kept (zcodeTeamKeyOf, retried after a minute, "sign in to ZCode again" when refused). Team requests go to the family's Anthropic base signed with the team key, no ZCode headers, never the Start Plan. Team usage asks bigmodel.cn (or api.z.ai) /api/monitor/usage/quota/limit?type=2 with the key and Authorization, Bigmodel-Organization, Bigmodel-Project, Set-Language, Accept-Language: CREDIT_LIMIT windows, unit 3 "5 hours" and unit 6 weekly, "current / total" when there is no remaining; /api/biz/customer-package-reset/list?targetType=TEAM gives the resets, fiveHourResets and weekResets counted where available is true, the soonest expireTime as until; the plan's end from querySubscribeDetail, Renew off. ResetCredits gains byWindow/fiveHour/weekly and Words(), "2 five-hour resets · 1 weekly reset" in the CLI, TUI and GUI (en and zh), with no "Use a reset" button for them since they are spent on the vendor's page. The Zhipu GLM key provider (planquota.go) falls back to the team query when the personal one fails or has no windows, adding the team headers when a ZCode team login holds that key. Personal accounts, Coding Plan and Start Plan, are routed, signed and metered as before. Tested with a new internal/provider/zcode_team_test.go against a fake bigmodel.cn/Z.ai in a sandbox HOME, no real tokens: a BigModel team sign-in through StartSignIn("zcode:bigmodel") is added (redirect param, plan "GLM Coding Team Pro", key made with keyType 2, org/project kept), its requests at the BigModel base signed with the team key and no ZCode headers, usage 5 hours 42% "420 / 1000" with a 5h span and weekly 10%, the quota headers sent, resets 2 five-hour and 1 weekly until 2026-10-05, plan until 2026-12-31; an unassigned seat is refused with no key made; ZCode's own team login from credentials + setting.json is found and metered while an individual plan stays as it was; the key provider's team fallback. TestPlanQuotas gains the type=2 case; the zcode Start Plan and account tests pass. A new internal/gui/tests/zcode-site.test.cjs, Chromium and WebKit, en and zh: starting ZCode's sign-in asks the site and posts nothing, BigModel posts {agent:"zcode",site:"bigmodel"} and keeps it, and the by-window resets read in both languages; signin-callback, click-scroll and login-import pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass (TestUserPath timed out under load and passes alone). Not tried against a real account: BigModel's OAuth sign-in and poll shape, the Set-Language values, customer-package-reset's field formats, querySubscribeDetail on Z.ai, type=2 for a pasted key with no org/project, and the time formats are from ZCode's code, not seen on the wire; not tried in the real Wails window.
2026-09-30 04:15:06 +08:00

949 lines
29 KiB
Go

package provider
// 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"
"strings"
"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"`
}
// ---- 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)
}
}
}
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
}
key = k.JWT
zcodeSourceHeaders(req)
}
req.Header.Del("Authorization")
req.Header.Set("x-api-key", key)
req.Header.Set("Authorization", "Bearer "+key)
return nil
}
acct.models = func() []catalog.Model {
if zcodeOnStart(nil, k) {
return zcodeStartModels
}
return zcodeModels
}
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(zcodeOnStart(nil, k)), 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.
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 x.Percent != nil {
w.Used = *x.Percent
}
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)
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 != "" {
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)
}
// ---- 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
}
who := zcodeWho(got.User.Email, got.User.Name, got.User.ID)
k, plan, err := zcodeSignedIn(ctx, site, token, strings.TrimSpace(got.Token))
if err != nil {
fail(err.Error())
return
}
auth, _ := json.Marshal(k)
ownUser, _, ok := zcodeOwn()
if !ok {
ownUser = ""
}
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())
}
}
}
if jwt != "" {
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
}
}
}
if team != "" {
return zcodeKey{}, "", errors.New(string(team))
}
if err != nil {
return zcodeKey{}, "", err
}
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
}