Files
yetone b69b0e8100 usage: a GLM Coding Plan key's card on bigmodel.cn or Z.ai shows its reset cards, five-hour and weekly, counted from customer-package-reset/list (targetType=PERSONAL) as the vendor's usage page counts them: only those still available; a list that can't be read shows none, never 0. Read only, never spent from magpie (#1191)
The list is asked with the plan's key, bare in Authorization, at the host its
term (subscription/list) is asked at, so open.bigmodel.cn and api.z.ai alike;
a ZCode account on the built-in gets the same. The tooltip says "The plan's
resets" for a person's own, "The team plan's" for a team's (ResetCredits.Team).

Tested: the reporter's literal reply (ids and times filled in, same shape)
in TestZhipuPersonalResets, TestPlanQuotas and TestZCodeAccounts, each
failing with the change reverted; reset-each-title in Chromium and WebKit,
en/zh/ja/de; a real Z.ai key through zhipuPersonalResets (1 five-hour,
1 weekly). Not tried: a real bigmodel.cn key.
2026-10-08 05:57:48 +08:00

409 lines
14 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 team's GLM Coding Plan (团队套餐), bought by an organization on Z.ai or
// BigModel (智谱) and handed out as seats to its members, is served where a
// person's plan is (api.z.ai/api/anthropic, open.bigmodel.cn/api/anthropic)
// but to a key of the team's project, not of the member's own. As ZCode
// 3.14.3 does it (host/index.js):
//
// - the account's organizations (getCustomerInfo) have the team's
// project, of projectType 2 (isBigModelTeamCodingPlanProject);
// - the seat is told by /api/biz/team/subscribe/product/querySubscribeDetail
// asked with the business sign-in and the project's bigmodel-organization
// and bigmodel-project headers: usable when status is EFFECTIVE and
// memberGrantStatus VALID, UNASSIGNED when the admin gave no seat,
// EXPIRED when the team's plan ran out (fetchTeamCodingPlanEntitlement);
// - the key is the project's zcode-team-api-key of keyType 2, made when it
// isn't there, with its secret (ensureBigModelTeamPlanProjectApiKeyWithStatus,
// copyBigModelTeamPlanProjectApiKeySecret); requests carry it as a
// person's key is carried, with no more headers;
// - what is used is /api/monitor/usage/quota/limit?type=2 with that key and
// the two headers (buildQuotaLimitUrl, createBigModelUsageHeaders): a
// CREDIT_LIMIT for the five hours (unit 3) and one for the week (unit 6).
//
// BigModel's business API is bigmodel.cn, Z.ai's api.z.ai; BigModel's
// sign-in token goes bare in Authorization, Z.ai's business one as a
// Bearer. The resets a team member may spend are listed by
// /api/biz/customer-package-reset/list?targetType=TEAM, as bigmodel.cn's
// own usage page asks it.
//
// ZCode keeps no team key in its credential store: it finds it when it is
// switched to the team's plan (~/.zcode/v2/setting.json's
// providerFamilyConnectionSelections, resolveAccountTeamPlanRuntimeApiKey),
// and so does magpie for ZCode's own account.
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
func (k zcodeKey) team() bool { return k.Org != "" && k.Project != "" }
// zcodeBizRoot is the business API of the site a plan is served from.
func zcodeBizRoot(base string) string {
if h := hostOf(base); h == hostOf(ZCodeBigModelBase) || strings.HasSuffix(h, "bigmodel.cn") {
return zcodeBigModelAPI
}
return zcodeRoot(base)
}
// zcodeTeamHeaders are what the team's business and usage endpoints are
// asked with besides the Authorization.
func zcodeTeamHeaders(base, org, project string) map[string]string {
lang := "en"
if zcodeBizRoot(base) == zcodeBigModelAPI {
lang = "zh"
}
return map[string]string{
"Bigmodel-Organization": org,
"Bigmodel-Project": project,
"Set-Language": lang,
"Accept-Language": "en-US,en",
}
}
// zcodeTeamRefusal is why a sign-in found a team but no seat on its plan.
type zcodeTeamRefusal string
type zcodeTeamProject struct{ org, project string }
// teamProjects are the account's team coding plan projects.
func (c zcodeCustomer) teamProjects() []zcodeTeamProject {
var out []zcodeTeamProject
for _, o := range c.Orgs {
for _, p := range o.Projects {
if o.ID != "" && p.ID != "" && fmt.Sprint(p.Type) == "2" {
out = append(out, zcodeTeamProject{o.ID, p.ID})
}
}
}
return out
}
// zcodeTeamDetail is a team seat, as querySubscribeDetail tells it.
type zcodeTeamDetail struct {
Has *bool `json:"hasSubscription"`
Status string `json:"status"`
Grant string `json:"memberGrantStatus"`
Product string `json:"productName"`
ID any `json:"productId"`
End any `json:"subscribeEndTime"`
}
func (d zcodeTeamDetail) usable() bool {
return (d.Has == nil || *d.Has) && strings.EqualFold(d.Status, "EFFECTIVE") && strings.EqualFold(d.Grant, "VALID")
}
// end is when the team's plan runs out: a time in Beijing, or a Unix time
// in seconds or milliseconds.
func (d zcodeTeamDetail) end() *time.Time {
return zcodeWhen(d.End)
}
func zcodeWhen(v any) *time.Time {
if s, ok := v.(string); ok {
if t := zhipuTime(s); t != nil {
return t
}
}
if n, ok := zcodeNum(v); ok && n > 0 {
var t time.Time
if n > 1e12 {
t = time.UnixMilli(int64(n))
} else {
t = time.Unix(int64(n), 0)
}
return &t
}
return nil
}
func zcodeTeamDetailOf(ctx context.Context, root, auth, base, org, project string) (zcodeTeamDetail, error) {
var d zcodeTeamDetail
err := zcodeCallH(ctx, http.MethodGet, root+"/api/biz/team/subscribe/product/querySubscribeDetail", auth, zcodeTeamHeaders(base, org, project), nil, &d)
return d, err
}
// zcodeTeamSignIn is the account's seat on a team's plan: the first team
// project whose plan is in force and gives it a seat, and that project's
// key. When there is none, why says what there was.
func zcodeTeamSignIn(ctx context.Context, site, root, auth string, info zcodeCustomer) (k zcodeKey, plan string, why zcodeTeamRefusal, err error) {
base := zcodeSiteBase(site)
ps := info.teamProjects()
if len(ps) == 0 {
return zcodeKey{}, "", "", errors.New("no team plan")
}
var last error
for _, p := range ps {
d, err := zcodeTeamDetailOf(ctx, root, auth, base, p.org, p.project)
switch {
case err != nil:
last = err
continue
case d.usable():
case strings.EqualFold(d.Status, "EFFECTIVE") && strings.EqualFold(d.Grant, "UNASSIGNED"):
if why == "" {
why = zcodeTeamRefusal(fmt.Sprintf("this %s account is in a team with a GLM Coding Plan but has no seat on it yet — ask the team's admin to give it one, then add it again", zcodeSiteName(site)))
}
continue
case strings.EqualFold(d.Status, "EXPIRED"):
if why == "" {
why = zcodeTeamRefusal(fmt.Sprintf("the %s team's GLM Coding Plan this account is in has expired", zcodeSiteName(site)))
}
continue
default:
continue
}
keys := root + "/api/biz/v1/organization/" + url.PathEscape(p.org) + "/projects/" + url.PathEscape(p.project) + "/api_keys"
key, err := zcodeProjectKey(ctx, site, keys, auth, zcodeTeamHeaders(base, p.org, p.project), map[string]any{"name": "zcode-team-api-key", "keyType": 2})
if err != nil {
return zcodeKey{}, "", "", fmt.Errorf("the team's GLM Coding Plan: %w", err)
}
return zcodeKey{Key: key, Base: base, Token: auth, Org: p.org, Project: p.project}, firstNonEmpty(d.Product, "GLM Coding Team"), "", nil
}
if why != "" {
return zcodeKey{}, "", why, errors.New(string(why))
}
return zcodeKey{}, "", "", fmt.Errorf("the team's GLM Coding Plan: %v", firstErr(last, "no seat on it"))
}
// ---- ZCode's own account on a team's plan -------------------------------------
// zcodeOwnTeam is ZCode's own account when ZCode is switched to a team's
// plan: the project it picked and the sign-in its key is found with.
func zcodeOwnTeam(store map[string]string, secret []byte) (zcodeKey, bool) {
home, _ := os.UserHomeDir()
var set struct {
Domain string `json:"providerFamilyDomain"`
Sel map[string]struct {
Kind string `json:"kind"`
Org string `json:"organizationId"`
Project string `json:"projectId"`
} `json:"providerFamilyConnectionSelections"`
Legacy map[string]string `json:"modelProviderFamilySelectedKeys"` // before providerFamilyConnectionSelections (migrateLegacyAccountConnectionSettings)
}
if !readJSON(filepath.Join(home, ".zcode", "v2", "setting.json"), &set) {
return zcodeKey{}, false
}
families := []string{"zai", "bigmodel"}
if set.Domain == "zai" || set.Domain == "bigmodel" {
families = []string{set.Domain}
}
for _, fam := range families {
org, project := "", ""
if s, ok := set.Sel[fam]; ok {
if s.Kind == "team-coding-plan" {
org, project = strings.TrimSpace(s.Org), strings.TrimSpace(s.Project)
}
} else if rest, ok := strings.CutPrefix(strings.TrimSpace(set.Legacy[fam]), "team-plan:builtin:"+fam+"-coding-plan:"); ok {
if parts := strings.Split(rest, ":"); len(parts) == 3 {
org, _ = url.PathUnescape(parts[1])
project, _ = url.PathUnescape(parts[2])
}
}
if org == "" || project == "" {
continue
}
v, ok := store["oauth:"+fam+":access_token"]
if !ok {
continue
}
tok, ok := zcodeDecrypt(secret, v)
if tok = strings.TrimSpace(tok); !ok || tok == "" {
continue
}
if jv, ok := store["zcodejwttoken"]; ok && fam == "bigmodel" {
if jwt, ok := zcodeDecrypt(secret, jv); ok && strings.TrimSpace(jwt) == tok {
continue // a stale token ZCode won't use either
}
}
base := ZCodeZaiBase
if fam == "bigmodel" {
base = ZCodeBigModelBase
}
return zcodeKey{Base: base, Token: tok, Org: org, Project: project}, true
}
return zcodeKey{}, false
}
// zcodeTeamKeys are the team keys found for accounts that keep none,
// ZCode's own: asked again after a failure a minute later.
var zcodeTeamKeys = struct {
sync.Mutex
m map[string]zcodeTeamKeyAt
}{m: map[string]zcodeTeamKeyAt{}}
type zcodeTeamKeyAt struct {
key string
err error
at time.Time
}
// zcodeTeamKeyOf is the team project's key for an account that keeps none.
func zcodeTeamKeyOf(ctx context.Context, k zcodeKey) (string, error) {
if k.Key != "" {
return k.Key, nil
}
id := k.Token + "\x00" + k.Org + "\x00" + k.Project
zcodeTeamKeys.Lock()
got, ok := zcodeTeamKeys.m[id]
zcodeTeamKeys.Unlock()
if ok && (got.err == nil || time.Since(got.at) < time.Minute) {
return got.key, got.err
}
if ctx == nil {
ctx = context.Background()
}
site := "zai"
if zcodeBizRoot(k.Base) == zcodeBigModelAPI {
site = "bigmodel"
}
root := zcodeBizRoot(k.Base)
keys := root + "/api/biz/v1/organization/" + url.PathEscape(k.Org) + "/projects/" + url.PathEscape(k.Project) + "/api_keys"
key, err := zcodeProjectKey(ctx, site, keys, k.Token, zcodeTeamHeaders(k.Base, k.Org, k.Project), map[string]any{"name": "zcode-team-api-key", "keyType": 2})
if err != nil {
err = fmt.Errorf("ZCode's team plan: %w — sign in to ZCode again", err)
}
zcodeTeamKeys.Lock()
zcodeTeamKeys.m[id] = zcodeTeamKeyAt{key, err, time.Now()}
zcodeTeamKeys.Unlock()
return key, err
}
// ---- allowance ------------------------------------------------------------------
// zcodeTeamQuota is a team seat's allowance: the team plan's five hours
// and week, what is left of its resets, and the plan's name and end.
func zcodeTeamQuota(ctx context.Context, l Login, k zcodeKey) SubscriptionQuota {
q := SubscriptionQuota{Provider: "zcode", Name: "ZCode", Icon: "zcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
key, err := zcodeTeamKeyOf(ctx, k)
if err != nil {
q.Error = err.Error()
return q
}
ws, err := zhipuTeamWindows(ctx, zcodeBizRoot(k.Base), key, k.Base, k.Org, k.Project)
if err != nil {
q.Error = err.Error()
return q
}
q.Windows = ws
if k.Token == "" {
return q
}
root := zcodeBizRoot(k.Base)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
q.Resets = zhipuTeamResets(ctx, root, k.Token, k.Base, k.Org, k.Project)
}()
if d, err := zcodeTeamDetailOf(ctx, root, k.Token, k.Base, k.Org, k.Project); err == nil && d.usable() {
if d.Product != "" {
q.Plan = d.Product
}
if t := d.end(); t != nil {
q.Until, q.Renew = t, "off"
}
}
wg.Wait()
return q
}
// zhipuTeamWindows asks a team's windows: the quota with type=2, the key
// in Authorization and the project in the headers.
func zhipuTeamWindows(ctx context.Context, root, key, base, org, project string) ([]QuotaWindow, error) {
var data zhipuLimits
if err := zcodeCallH(ctx, http.MethodGet, root+"/api/monitor/usage/quota/limit?type=2", key, zcodeTeamHeaders(base, org, project), nil, &data); err != nil {
return nil, err
}
return data.windows(), nil
}
// zhipuTeamResets is how many resets a team member may spend, of the five
// hours and of the week: those of customer-package-reset/list still
// available. nil when it can't be told or there are none.
func zhipuTeamResets(ctx context.Context, root, auth, base, org, project string) *ResetCredits {
r := zhipuResets(ctx, root+"/api/biz/customer-package-reset/list?targetType=TEAM", auth, zcodeTeamHeaders(base, org, project))
if r != nil {
r.Team = true
}
return r
}
// zhipuPersonalResets is how many resets a GLM Coding Plan of a person's
// own holds, as bigmodel.cn's usage page lists them (#1191), asked with
// the plan's key where its term is (zhipuTerm). Only read: a reset is
// spent on the vendor's own page, never from magpie.
func zhipuPersonalResets(ctx context.Context, root, key string) *ResetCredits {
return zhipuResets(ctx, root+"/api/biz/customer-package-reset/list?targetType=PERSONAL", key, nil)
}
// zhipuResets reads customer-package-reset/list: the resets of the five
// hours and of the week, of which only those still available count (one
// run out stays listed a week, available false). nil when the list
// can't be read, so no count is shown rather than 0, and when it holds
// none.
func zhipuResets(ctx context.Context, u, auth string, hdr map[string]string) *ResetCredits {
type reset struct {
Available bool `json:"available"`
Expire any `json:"expireTime"`
}
var data struct {
FiveHour []reset `json:"fiveHourResets"`
Week []reset `json:"weekResets"`
}
if err := zcodeCallH(ctx, http.MethodGet, u, auth, hdr, nil, &data); err != nil {
return nil
}
r := &ResetCredits{ByWindow: true}
count := func(rs []reset, window string) (n int) {
for _, x := range rs {
if !x.Available {
continue
}
n++
t := zcodeWhen(x.Expire)
if t != nil && (r.Until == nil || t.Before(*r.Until)) {
r.Until = t
}
r.Each = append(r.Each, ResetCard{Until: t, Window: window})
}
return n
}
r.FiveHour, r.Weekly = count(data.FiveHour, "fiveHour"), count(data.Week, "weekly")
r.Count = r.FiveHour + r.Weekly
sortResetCards(r.Each)
if r.Count == 0 {
return nil
}
return r
}
// zhipuTeamOf is the team project a GLM key belongs to, when that key is a
// ZCode account's team key magpie holds.
func zhipuTeamOf(key string) (org, project string) {
for _, l := range zcodeLogins() {
if l.key.team() && l.key.Key == key {
return l.key.Org, l.key.Project
}
}
return "", ""
}