Files
magpie/internal/provider/zcode.go
T

644 lines
19 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.
//
// The plan has no model list to ask; the models are ZCode's, as its
// built-in config lists them for the coding plan.
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"
"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"
)
// 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},
{ID: "GLM-5.3-Flash", Name: "GLM-5.3-Flash", Context: 1_000_000},
{ID: "GLM-5.2", Name: "GLM-5.2", Context: 1_000_000},
{ID: "GLM-5-Turbo", Name: "GLM-5-Turbo", Context: 200_000},
}
// zcodeKey is a coding plan's key and where it is served.
type zcodeKey struct {
Key string `json:"apiKey"`
Base string `json:"base"`
}
// ---- 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 and its coding plan's key;
// ok is false when it has none.
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 k.Key == "" {
return "", zcodeKey{}, false
}
var info struct {
Email string `json:"email"`
Name string `json:"name"`
ID string `json:"user_id"`
}
if v, ok := store["oauth:zai:user_info"]; ok {
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 == "" {
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, "ZCode's own sign-in; sign out in ZCode", 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}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
req.Header.Del("Authorization")
req.Header.Set("x-api-key", k.Key)
req.Header.Set("Authorization", "Bearer "+k.Key)
return nil
}
acct.models = func() []catalog.Model { return zcodeModels }
return Provider{ID: "zcode", Name: "ZCode", Icon: "zcode", Anthropic: k.Base, 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 {
q := SubscriptionQuota{Provider: "zcode", Name: "ZCode", Icon: "zcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
var data 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"`
}
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:]
}
for _, x := range data.Limits {
span := zcodeSpan(x.Unit, x.Number)
w := QuotaWindow{Name: zcodeWindowName(span), Used: x.Percent, Span: span}
if x.Usage > 0 {
used := x.Usage - x.Remaining
w.Used = 100 * used / x.Usage
w.Display = fmt.Sprintf("%s / %s", compactNumber(used), compactNumber(x.Usage))
}
if x.Reset > 0 {
t := time.UnixMilli(x.Reset)
w.ResetsAt = &t
}
q.Windows = append(q.Windows, w)
}
return q
}
// zcodeSpan reads a limit's window: unit 3 counts hours, 6 weeks (and
// 4, 5 days and months by the same count).
func zcodeSpan(unit, n int) time.Duration {
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 {
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)
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, 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) error {
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": "zai"}, &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 sends the browser back, as ZCode sets it
back := zcodeAPI + "/app/oauth/login?" + url.Values{"redirect": {"zcode://oauth/callback"}, "app_version": {zcodeAppVersion}}.Encode()
q := u.Query()
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"`
Zai struct {
AccessToken string `json:"access_token"`
} `json:"zai"`
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)
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 Z.ai")
return
case got.Status != "ready" || got.Zai.AccessToken == "":
fail("ZCode sign-in: unexpected answer " + got.Status)
return
}
who := zcodeWho(got.User.Email, got.User.Name, got.User.ID)
k, plan, err := zcodeMintKey(ctx, got.Zai.AccessToken)
if err != nil {
fail(err.Error())
return
}
auth, _ := json.Marshal(k)
if err := addSideLogin(savedLogin{Agent: "zcode", User: who, Plan: plan, Auth: auth}, func(savedLogin) {}); err != nil {
fail(err.Error())
return
}
ownUser, _, ok := zcodeOwn()
s.finish(SignInState{State: "done", User: who, Plan: plan, Using: ok && strings.EqualFold(ownUser, who)})
return
}
}()
return nil
}
// zcodeMintKey turns a Z.ai sign-in into its coding plan key: Z.ai's
// business token, then the key named zcode-api-key in the account's default
// project, made if it isn't there, with its secret.
func zcodeMintKey(ctx context.Context, zaiToken string) (zcodeKey, string, error) {
var biz struct {
Token string `json:"access_token"`
}
if err := zcodeCall(ctx, http.MethodPost, zcodeZaiAPI+"/api/auth/z/login", "", map[string]string{"token": zaiToken}, &biz); err != nil || biz.Token == "" {
return zcodeKey{}, "", fmt.Errorf("Z.ai sign-in: %v", firstErr(err, "no token"))
}
bearer := "Bearer " + biz.Token
var info 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"`
}
if err := zcodeCall(ctx, http.MethodGet, zcodeZaiAPI+"/api/biz/customer/getCustomerInfo", bearer, nil, &info); err != nil {
return zcodeKey{}, "", fmt.Errorf("Z.ai account: %w", err)
}
// 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{}, "", errors.New("this Z.ai account has no project for an API key")
}
keys := zcodeZaiAPI + "/api/biz/v1/organization/" + url.PathEscape(org) + "/projects/" + url.PathEscape(proj) + "/api_keys"
type apiKey struct {
Name string `json:"name"`
APIKey string `json:"apiKey"`
}
var list []apiKey
if err := zcodeCall(ctx, http.MethodGet, keys, bearer, nil, &list); err != nil {
return zcodeKey{}, "", fmt.Errorf("Z.ai API keys: %w", err)
}
id := ""
for _, k := range list {
if k.Name == "zcode-api-key" {
id = strings.TrimSpace(k.APIKey)
}
}
if id == "" {
var made apiKey
if err := zcodeCall(ctx, http.MethodPost, keys, bearer, map[string]string{"name": "zcode-api-key"}, &made); err != nil {
return zcodeKey{}, "", fmt.Errorf("Z.ai API key: %w", err)
}
id = strings.TrimSpace(made.APIKey)
}
var secret struct {
Secret string `json:"secretKey"`
}
if id != "" {
if err := zcodeCall(ctx, http.MethodGet, keys+"/copy/"+url.PathEscape(id), bearer, nil, &secret); err != nil {
return zcodeKey{}, "", fmt.Errorf("Z.ai API key: %w", err)
}
}
if id == "" || strings.TrimSpace(secret.Secret) == "" {
return zcodeKey{}, "", errors.New("Z.ai gave no API key")
}
k := zcodeKey{Key: id + "." + strings.TrimSpace(secret.Secret), Base: ZCodeZaiBase}
plan, err := zcodePlan(ctx, k)
if err != nil {
return zcodeKey{}, "", fmt.Errorf("GLM Coding Plan: %w", err)
}
if plan == "" {
return zcodeKey{}, "", errors.New("this Z.ai account has no GLM Coding Plan — subscribe at z.ai/subscribe, then add it again")
}
return k, plan, nil
}
func firstErr(err error, otherwise string) string {
if err != nil {
return err.Error()
}
return otherwise
}