Files
magpie/internal/provider/zcode_models.go
T
yetone 9992678c64 zcode: an account with no GLM Coding Plan uses ZCode's Start Plan (体验套餐) where ZCode does, with ZCode's own token, and shows its allowance (#236 tasselx: ZCode 3.14.4, magpie 0.1.410, a trial-only account, the free quota not read and every request refused with rate_limit_error "ZCode: [1113][Insufficient balance or no resource package. Please recharge.]"; #225 xiaozhu1337: ZCode ships a built-in trial package) — magpie sent every ZCode account to the Coding Plan's endpoint, api.z.ai/api/anthropic, with the account's zcode-api-key, and read its allowance only from /api/monitor/usage/quota/limit. That key is billed to a Coding Plan, or else to the account's API balance, so an account with only the Start Plan got 1113. ZCode itself (ZCode.app 3.14.3's zcode-builtin.json providerRules and host/index.js: resolveCurrent, buildZaiStartPlanBalanceUrl, hasActiveStartPlan, isZaiStartPlanIdentity, normalizeStartPlanExpiry, normalizeZaiStartPlanBalanceLimits, pollPendingOAuth) serves the Start Plan (account:zai-start-plan and account:bigmodel-start-plan) from zcode.z.ai/api/v1/zcode-plan/anthropic, keyed by its own session token (the credential store's zcodejwttoken, the token its sign-in's poll hands back, which magpie dropped), and reads what is left from /api/v1/zcode-plan/billing/balance?app_version= with that token. Now, in a new zcode_start.go: an account keeps ZCode's token beside its key (zcodeKey.JWT; zcodeOwn reads zcodejwttoken, and user_info from oauth:bigmodel:user_info when there is no Z.ai one; an account with the token and no coding plan key is an account). Whether it is on the Start Plan is asked of /api/biz/subscription/list (a Coding Plan: as before; none: the Start Plan; can't tell: the Start Plan if its balance has an active one), kept 10 minutes (1 when unsure); with no token it is the Coding Plan as before, asking nothing, and with no key it is the Start Plan. On the Start Plan a request is moved to zcode-plan/anthropic, signed with the token (x-api-key and Bearer) and ZCode's own headers (User-Agent ZCode/<ver>, X-ZCode-App-Version, X-Title, HTTP-Referer, X-Platform), refused with "sign in to ZCode again" once the token has run out; its models are the Start Plan's (zcodePlanID account:zai-start-plan for ZCode's config, zcodeModels less GLM-5.3 before it is read). Its allowance is the balance as ZCode reads it: a plan still "active" past ends_at (by server_time) is expired and the buckets of expired plans left out; the plan's name (Start Plan) with Until its end and Renew off; a window per bucket, named by its show_name (or its models, or Credits), Used and "used / total" from total/used/remaining units (numbers or strings), ResetsAt its expires_at, Span from its entitlement's period (daily/weekly/monthly) or period_start..end, and matched to the bucket's models. Signing in from magpie no longer refuses an account with no Coding Plan: with an active Start Plan it is added on that (its key kept too, so a Coding Plan bought later is used), and refused only when the Start Plan is over as well ("this Z.ai account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started — subscribe at z.ai/subscribe, then add it again"). Accounts with a Coding Plan are routed, signed and metered as before. The provider keeps the name ZCode (#225 asked for "GLM Coding Plan"; ZCode names these plans "Z.AI Individual Coding Plan" and "Start Plan", and the plan line already shows "GLM Coding <Level>"). Tested with a new internal/provider/zcode_start_test.go against a fake Z.ai/zcode.z.ai in a sandbox HOME, no real tokens: ZCode signed in with only its token (BigModel user_info) is an account, its provider served at zcode-plan/anthropic, a request signed with the token and ZCode's headers, the Start Plan's models and plan id, its allowance (Start Plan, Until, Renew off, GLM-5.1's window 25%, "250000 / 1000000", reset, a day's span, matching GLM-5.1 only); an expired plan and one active past its end give none; with a key and the token, no Coding Plan moves the request (query kept) to the Start Plan and it reaches it with the token, a Coding Plan keeps it on api.z.ai with the key and no ZCode headers, no token asks nothing, an expired token is refused; a sign-in with only the Start Plan is added on it, with a Coding Plan on that plan, with neither refused naming both. TestZCodeAccounts passes unchanged. go build -tags nogui (and GOOS=linux, GOOS=windows), go vet and go test -tags nogui ./... pass (TestUpdateCLI, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin and TestUserPath timed out once each under load beside other builds and pass alone). Not tried with a real Start Plan account: the endpoint's acceptance of the token as x-api-key/Bearer and whether it needs ZCode's headers, the balance's real field values and period strings, what subscription/list returns for a trial-only account, and ZCode 3.14.4 (read from 3.14.3) are from ZCode's code, not seen on the wire.
2026-09-30 02:15:50 +08:00

245 lines
6.9 KiB
Go

package provider
// A coding plan's models are the ones ZCode offers for it, from ZCode's
// built-in provider config: zcode.z.ai names the release to use
// (/api/v1/client/configs, its configs.builtin_provider_config_json), a
// JSON on cdn-zcode.z.ai that lists each plan's models (by providerId,
// "account:zai-individual-coding-plan") and what each model is, by rules
// matched in turn on its id. ZCode keeps whichever of that release and the
// one it ships with (or cached last) is the later revision, and so does
// magpie. Nothing here is the account's: neither is signed.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"github.com/yetone/magpie/internal/catalog"
)
type zcodeBuiltin struct {
Revision int `json:"revision"`
Config struct {
ProviderConfigRules struct {
ProviderRules []struct {
ProviderID string `json:"providerId"`
BuiltinModelIDs []string `json:"builtinModelIds"`
} `json:"providerRules"`
} `json:"providerConfigRules"`
ModelConfigRules struct {
ModelRules []struct {
Match string `json:"modelMatch"`
Config zcodeModelRule `json:"config"`
} `json:"modelRules"`
ProviderModels []struct {
ProviderID string `json:"providerId"`
ModelID string `json:"modelId"`
Config struct {
Enabled *bool `json:"enabled"`
} `json:"config"`
} `json:"builtinProviderModelRules"`
} `json:"modelConfigRules"`
} `json:"config"`
}
type zcodeModelRule struct {
Properties struct {
ContextWindow int `json:"contextWindow"`
InputFormat struct {
SupportsImage *bool `json:"supportsImage"`
} `json:"inputFormat"`
} `json:"properties"`
OptionSpecs struct {
MaxOutputTokens struct {
Max int `json:"max"`
} `json:"maxOutputTokens"`
ReasoningLevel struct {
Values []string `json:"values"`
} `json:"reasoningLevel"`
} `json:"optionSpecs"`
}
// zcodeEfforts are ZCode's reasoning levels for a model in magpie's words:
// "disabled" is none, and "enabled", a model's one level when it has no
// others (GLM-5-Turbo's), is high.
func zcodeEfforts(values []string) []string {
var out []string
for _, v := range values {
switch v {
case "disabled":
v = "none"
case "enabled":
v = "high"
}
if !slices.Contains(out, v) {
out = append(out, v)
}
}
return out
}
// zcodePlanID is ZCode's providerId for the coding plan served at base.
// A team's plan has the individual one's models.
func zcodePlanID(base string) string {
if strings.Contains(base, "/zcode-plan/") { // ZCode's Start Plan (zcode_start.go)
return "account:zai-start-plan"
}
if strings.Contains(base, "bigmodel.cn") {
return "account:bigmodel-individual-coding-plan"
}
return "account:zai-individual-coding-plan"
}
// zcodeFetchModels is the plan at base's models, as ZCode has them now.
func zcodeFetchModels(ctx context.Context, base string) ([]catalog.Model, error) {
b, err := zcodeRemoteBuiltin(ctx)
if l, ok := zcodeLocalBuiltin(); ok && (err != nil || l.Revision > b.Revision) {
b, err = l, nil
}
if err != nil {
return nil, err
}
ms := b.models(zcodePlanID(base))
if len(ms) == 0 {
return nil, fmt.Errorf("ZCode's config (revision %d) has no models for %s", b.Revision, zcodePlanID(base))
}
return ms, nil
}
func (b zcodeBuiltin) models(plan string) []catalog.Model {
var ids []string
on := map[string]bool{}
add := func(id string) {
if k := strings.ToLower(id); !on[k] {
on[k] = true
ids = append(ids, id)
}
}
for _, r := range b.Config.ProviderConfigRules.ProviderRules {
if r.ProviderID == plan {
for _, id := range r.BuiltinModelIDs {
add(id)
}
}
}
off := map[string]bool{}
for _, r := range b.Config.ModelConfigRules.ProviderModels {
if r.ProviderID != plan {
continue
}
if r.Config.Enabled != nil && !*r.Config.Enabled {
off[strings.ToLower(r.ModelID)] = true
continue
}
add(r.ModelID)
}
var out []catalog.Model
for _, id := range ids {
if off[strings.ToLower(id)] {
continue
}
m := catalog.Model{ID: id, Name: id}
// every rule that matches the id, later ones over earlier ones
for _, r := range b.Config.ModelConfigRules.ModelRules {
re, err := regexp.Compile("^(?i:" + r.Match + ")$")
if err != nil || !re.MatchString(id) {
continue
}
c := r.Config
if c.Properties.ContextWindow > 0 {
m.Context = c.Properties.ContextWindow
}
if c.OptionSpecs.MaxOutputTokens.Max > 0 {
m.Output = c.OptionSpecs.MaxOutputTokens.Max
}
if v := c.Properties.InputFormat.SupportsImage; v != nil {
m.Images, m.ImageInput = *v, v
}
if vs := c.OptionSpecs.ReasoningLevel.Values; len(vs) > 0 {
m.Efforts = zcodeEfforts(vs)
}
}
out = append(out, m)
}
return out
}
// zcodeRemoteBuiltin is the release zcode.z.ai names now.
func zcodeRemoteBuiltin(ctx context.Context) (zcodeBuiltin, error) {
var cfg struct {
Configs struct {
URL string `json:"builtin_provider_config_json"`
} `json:"configs"`
}
arch := runtime.GOARCH
if arch == "amd64" {
arch = "x64"
}
platform := runtime.GOOS
if platform == "windows" {
platform = "win32"
}
u := zcodeAPI + "/api/v1/client/configs?app_version=" + zcodeAppVersion + "&platform=" + platform + "-" + arch
if err := zcodeCall(ctx, http.MethodGet, u, "", nil, &cfg); err != nil {
return zcodeBuiltin{}, fmt.Errorf("ZCode's configs: %w", err)
}
if !strings.HasPrefix(cfg.Configs.URL, "https://") {
return zcodeBuiltin{}, errors.New("ZCode's configs name no provider config")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.Configs.URL, nil)
if err != nil {
return zcodeBuiltin{}, err
}
req.Header.Set("User-Agent", "ZCode/"+zcodeAppVersion)
res, err := http.DefaultClient.Do(req)
if err != nil {
return zcodeBuiltin{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return zcodeBuiltin{}, fmt.Errorf("ZCode's provider config: %s", res.Status)
}
var b zcodeBuiltin
if err := json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&b); err != nil {
return zcodeBuiltin{}, fmt.Errorf("ZCode's provider config: %w", err)
}
return b, nil
}
// zcodeBuiltinFiles are where an installed ZCode keeps a release: the one
// it ships with, and the one it last took, under ~/.zcode.
var zcodeBuiltinFiles = func() []string {
home, _ := os.UserHomeDir()
fs, _ := filepath.Glob(filepath.Join(home, ".zcode", "v2", "runtime", "provider", "*", "*", "*", "zcode-builtin.json"))
if runtime.GOOS == "darwin" {
fs = append(fs, "/Applications/ZCode.app/Contents/Resources/config/provider/zcode-builtin.json")
}
return fs
}
// zcodeLocalBuiltin is the latest release an installed ZCode has.
func zcodeLocalBuiltin() (zcodeBuiltin, bool) {
var best zcodeBuiltin
found := false
for _, f := range zcodeBuiltinFiles() {
raw, err := os.ReadFile(f)
if err != nil {
continue
}
var b zcodeBuiltin
if json.Unmarshal(raw, &b) == nil && (!found || b.Revision > best.Revision) {
best, found = b, true
}
}
return best, found
}