Files
yetone fa9daa6945 docs: every built-in subscription a plugin serves once moved says so in its code, and AGENTS.md says where a fix for it goes (user: 对内置的订阅 provider 在代码层面和文档层面加上注释,防止又有别的 agent 在改内置订阅 provider 的代码而不知道生效的其实是对应的插件代码) — a moved Cursor, Devin, Grok, Command Code plan, Kiro, Zed, Qoder, Factory, Xiaomi MiMo, ZCode or WorkBuddy (provider.Moved; since v0.1.642 also the default for a new sign-in, provider.Adopt) is signed in, listed, requested and metered by its @magpie-community plugin, and none of the built-in's code in internal/provider/<name>*.go, internal/gateway/<name>.go, internal/zed or internal/qoder serves it; a fix made there alone never reaches those users. Each of those files now opens with a PLUGIN-SERVED notice naming the subscription's id, its plugin package, where the plugin lives (magpie-community/plugins, packages/<name>) and the mover whose min to raise so moved users get the fixed plugin (keepMovedCurrent); Qoder's adds that Qoder CN isn't moved and still runs there. AGENTS.md (CLAUDE.md imports it) lists the moved built-ins with their packages and movers, how to tell whether a user's is moved (move.state "plugin", onPlugins), the order a fix goes in (the plugin, publish, raise min, the built-in only for those not moved), the magpie code that does run for a moved one (internal/plugin, plugins.go, plugin_usage.go, pluginsignin.go, migrate*.go, app.js's subOf/pluginSubs/startPluginSignIn), and the parity tests. movers in migrate.go and SUBS in app.js point there too. TestMovedBuiltinsSayTheirPlugin fails for a mover whose code carries no PLUGIN-SERVED notice naming its id and package (it failed while a wrapped package name was split). Comments and docs only, no behaviour changed. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut failed once on their timing under load, pass alone; untouched).
2026-10-02 19:37:02 +08:00

900 lines
35 KiB
Go

package provider
// PLUGIN-SERVED (see AGENTS.md): Command Code's plan ("commandcode-plan") is
// a deprecated built-in subscription served by its plugin,
// @magpie-community/opencode-commandcode-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/commandcode) and raise the
// mover's min in internal/provider/migrate_side.go.
// A Command Code subscription is a commandcode.ai plan (Pro, GOAT, Max,
// Ultra — every one but Go comes with API access). Signing in to it, as
// `cmd auth login` does, mints an API key for the account; that key is
// served on Command Code's Provider API (/provider/v1: Chat, Responses and
// Anthropic's Messages) and billed against the plan's own credits and its
// 5-hour and weekly limits, not as pay-as-you-go.
//
// Go has no API access: its key is only taken where the CLI itself asks,
// POST /alpha/generate, in the CLI's own format (the gateway's
// commandcode.go). Which plan an account is on is asked of billing/
// subscriptions, and kept a while (cmdPlanNow).
//
// The CLI's own account is read, never changed, from ~/.commandcode/
// auth.json. Further accounts are signed in by magpie with the CLI's own
// browser sign-in: commandcode.ai/studio/auth/cli sends the new key to a
// callback on this machine, which answers as the CLI's does; their keys
// are kept in logins.json.
//
// Its id is commandcode-plan: "commandcode" is the keyed preset's, which a
// provider added with a key from Studio may already have.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// CommandCodePlanID is the subscription's id, and its sign-in's.
const CommandCodePlanID = "commandcode-plan"
// CommandCodeMaxOutput is the most max_tokens Command Code takes at
// /alpha/generate, for any model: more is refused ("Too big: expected
// number to be <=200000 at params.max_tokens"). models.dev gives some of
// its models more (DeepSeek V4's 384000, MiniMax M3's 512000).
const CommandCodeMaxOutput = 200_000
// CommandCodeOutputOf is the most a reply of a Command Code model may be
// asked for: the model's own limit, as models.dev gives it, within
// CommandCodeMaxOutput.
func CommandCodeOutputOf(model string) int {
if n := catalog.OutputOf(model); n > 0 && n < CommandCodeMaxOutput {
return n
}
return CommandCodeMaxOutput
}
// Where Command Code's API and its sign-in page are; vars so tests can
// point them elsewhere.
var (
cmdAPI = "https://api.commandcode.ai"
cmdStudio = "https://commandcode.ai"
)
// cmdModels are the plan's best-known models, before its list is fetched.
var cmdModels = []catalog.Model{
{ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Context: 1_000_000},
{ID: "claude-opus-5-5", Name: "Claude Opus 5.5", Context: 1_000_000},
{ID: "gpt-6-sol", Name: "GPT-6 Sol", Context: 1_050_000},
{ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Context: 1_000_000},
{ID: "deepseek/deepseek-v4-flash", Name: "DeepSeek V4 Flash", Context: 1_000_000},
{ID: "moonshotai/Kimi-K3", Name: "Kimi K3", Context: 1_000_000},
{ID: "zai-org/GLM-5.3", Name: "GLM-5.3", Context: 1_000_000},
{ID: "MiniMaxAI/MiniMax-M3", Name: "MiniMax M3", Context: 1_000_000},
}
// cmdGoModels are the models the Go plan is let use, as the CLI's own
// table has it (1.73.0): every model its picker shows (wD, less the
// hidden) but the "premium" ones and those it blocks for Go
// (cmdGoRefused). They stand in until Command Code's list is fetched
// (cmdGoFetch), and give that list the reasoning levels and pictures it
// doesn't say.
var cmdGoModels = []catalog.Model{
{ID: "gpt-6-luna", Name: "GPT-6 Luna", Context: 1_050_000, Images: true, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
{ID: "gpt-5.6-luna", Name: "GPT-5.6 Luna", Context: 1_050_000, Images: true, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
{ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Context: 1_000_000, Efforts: []string{"high", "max"}},
{ID: "deepseek/deepseek-v4-flash", Name: "DeepSeek V4 Flash", Context: 1_000_000, Efforts: []string{"high", "max"}},
{ID: "deepseek/deepseek-v4-flash-vision-exp", Name: "DeepSeek V4 Flash Vision (exp)", Context: 1_000_000, Images: true, Efforts: []string{"high", "max"}},
{ID: "deepseek/deepseek-v4-flash-fast", Name: "DeepSeek V4 Flash Fast", Context: 1_000_000, Efforts: []string{"low", "high", "max"}},
{ID: "deepseek/deepseek-v4.1-flash", Name: "DeepSeek V4.1 Flash", Context: 1_000_000, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "deepseek/deepseek-v4.1-flash-fast", Name: "DeepSeek V4.1 Flash Fast", Context: 1_000_000, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "moonshotai/Kimi-K3", Name: "Kimi K3", Context: 1_000_000, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "moonshotai/Kimi-K2.7-Code", Name: "Kimi K2.7 Code", Context: 256_000, Images: true},
{ID: "moonshotai/Kimi-K2.7-Code-Highspeed", Name: "Kimi K2.7 Code HighSpeed", Context: 262_000, Images: true},
{ID: "moonshotai/Kimi-K2.6", Name: "Kimi K2.6", Context: 256_000, Images: true},
{ID: "moonshotai/Kimi-K2.5", Name: "Kimi K2.5", Context: 256_000, Images: true},
{ID: "z-ai/glm-5.3-flash", Name: "GLM-5.3 Flash", Context: 1_048_576, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "z-ai/glm-5.3-flashx", Name: "GLM-5.3 FlashX", Context: 1_000_000, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "zai-org/GLM-5.3", Name: "GLM-5.3", Context: 1_000_000, Efforts: []string{"low", "high", "max"}},
{ID: "zai-org/GLM-5.2", Name: "GLM-5.2", Context: 1_000_000, Efforts: []string{"high", "max"}},
{ID: "zai-org/GLM-5.2-Fast", Name: "GLM-5.2 Fast", Context: 1_000_000},
{ID: "zai-org/GLM-5.1", Name: "GLM-5.1", Context: 200_000},
{ID: "zai-org/GLM-5", Name: "GLM-5", Context: 200_000},
{ID: "MiniMaxAI/MiniMax-M3", Name: "MiniMax M3", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "high"}},
{ID: "MiniMaxAI/MiniMax-M2.7", Name: "MiniMax M2.7", Context: 200_000},
{ID: "MiniMaxAI/MiniMax-M2.5", Name: "MiniMax M2.5", Context: 200_000},
{ID: "xiaomi/mimo-v2.6-pro", Name: "MiMo V2.6 Pro", Context: 1_048_576, Images: true},
{ID: "xiaomi/mimo-v2.6-flash", Name: "MiMo V2.6 Flash", Context: 1_048_576, Images: true},
{ID: "xiaomi/mimo-v2.5-pro", Name: "MiMo V2.5 Pro", Context: 1_000_000},
{ID: "xiaomi/mimo-v2.5", Name: "MiMo V2.5", Context: 1_000_000, Images: true},
{ID: "Qwen/Qwen3.8-Omni-Flash", Name: "Qwen 3.8 Omni Flash", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.8-Max-0902", Name: "Qwen 3.8 Max 0902", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.8-Max", Name: "Qwen 3.8 Max", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.8-27B", Name: "Qwen 3.8 27B", Context: 262_144, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.8-Flash", Name: "Qwen 3.8 Flash", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.7-Max", Name: "Qwen 3.7 Max", Context: 1_000_000},
{ID: "Qwen/Qwen3.7-Plus", Name: "Qwen 3.7 Plus", Context: 1_000_000, Images: true},
{ID: "Qwen/Qwen3.7-Flash", Name: "Qwen 3.7 Flash", Context: 1_000_000, Images: true},
{ID: "Qwen/Qwen3.6-Max-Preview", Name: "Qwen 3.6 Max Preview", Context: 200_000},
{ID: "Qwen/Qwen3.6-Plus", Name: "Qwen 3.6 Plus", Context: 200_000, Images: true},
{ID: "meituan/LongCat-2.0", Name: "LongCat 2.0", Context: 1_048_576},
{ID: "stepfun/Step-5-Preview", Name: "Step 5 Preview", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "high"}},
{ID: "stepfun/Step-3.7-Flash", Name: "Step 3.7 Flash", Context: 256_000, Images: true},
{ID: "stepfun/Step-3.5-Flash", Name: "Step 3.5 Flash", Context: 262_144},
{ID: "tencent/hy3-paid", Name: "Tencent Hy3", Context: 262_144},
{ID: "tencent/hy4-preview", Name: "Tencent Hy4 Preview", Context: 1_048_576, Efforts: []string{"low", "medium", "high"}},
{ID: "google/gemini-3.6-flash", Name: "Gemini 3.6 Flash", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "high"}},
{ID: "google/gemini-3.5-flash-lite", Name: "Gemini 3.5 Flash Lite", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "high"}},
{ID: "nvidia/nemotron-3-ultra-550b-a55b", Name: "Nemotron 3 Ultra", Context: 1_000_000},
{ID: "thinkingmachines/inkling", Name: "Inkling", Context: 256_000, Images: true},
{ID: "thinkingmachines/inkling-small", Name: "Inkling Small", Context: 1_000_000, Images: true},
{ID: "stealth/space-bunny-alpha", Name: "Space Bunny Alpha", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "high"}},
{ID: "stealth/pixel-canary", Name: "Pixel Canary", Context: 262_144, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "poolside/laguna-s-2.1-free", Name: "Laguna S 2.1", Context: 256_000},
{ID: "inclusionai/ling-3.0-flash-free", Name: "Ling 3.0 Flash", Context: 256_000},
{ID: "inclusionai/ling-3.0-flash-sante:free", Name: "Ling 3.0 Flash Sante", Context: 262_144},
{ID: "inclusionai/ling-3.1-flash:free", Name: "Ling 3.1 Flash", Context: 262_144, Efforts: []string{"low", "medium", "high"}},
{ID: "meta/muse-spark-1.2-contributor", Name: "Muse Spark 1.2 Contributor", Context: 1_048_576, Images: true, Efforts: []string{"low", "medium", "high", "xhigh"}},
{ID: "meta/muse-spark-1.3-contributor", Name: "Muse Spark 1.3 Contributor", Context: 1_048_576, Images: true, Efforts: []string{"low", "medium", "high", "xhigh"}},
{ID: "xai/grok-4.5", Name: "Grok 4.5", Context: 500_000, Images: true, Efforts: []string{"low", "medium", "high"}},
}
// cmdGoRefused are the models of Command Code's list the Go plan is
// refused, as the CLI's table has it (1.73.0): its "premium" ones, and
// those "individual-go" blocks —
//
// "individual-go":{allowedCategories:[qo],blockedModels:Xo=[…]}
//
// A model the table doesn't name the CLI lets any plan pick; one the plan
// hasn't after all is refused with MODEL_NOT_IN_PLAN.
var cmdGoRefused = map[string]bool{
// premium
"claude-sonnet-5": true, "claude-sonnet-4-6": true, "claude-fable-5-1": true, "claude-fable-5": true,
"claude-opus-5-5": true, "claude-opus-5": true, "claude-opus-4-8": true, "claude-opus-4-7": true,
"claude-haiku-4-5-20251001": true, "gpt-6-astra": true, "gpt-6.1-sol": true, "gpt-6-sol": true,
"gpt-5.6-terra": true, "gpt-5.5": true, "gpt-5.4": true, "gpt-5.3-codex": true, "gpt-5.4-mini": true,
"google/gemini-3.5-flash": true, "google/gemini-3.1-flash-lite": true, "sakana/fugu-ultra": true,
"meta/muse-spark-1.1": true,
// blocked for Go
"claude-sonnet-5-5": true, "gpt-5.6-sol": true, "xai/grok-4.6": true, "xai/grok-4.7": true,
"meta/muse-spark-1.2": true, "meta/muse-spark-1.3": true, "xiaomi/mimo-v2.6-pro-ultraspeed": true,
"google/gemini-3.7-flash": true, "google/gemini-3.8-flash": true,
}
// cmdGoFetch is the Go plan's models: Command Code's list, less what Go
// is refused. The list is the Provider API's, which answers without a key
// (Go's has no Provider API), and takes its reasoning levels and pictures
// from cmdGoModels.
func cmdGoFetch(ctx context.Context) ([]catalog.Model, error) {
base := cmdAPI + "/provider/v1"
ms, err := catalog.FetchURL(ctx, base+"/models", "", false, nil)
if err != nil {
return nil, err
}
var out []catalog.Model
for _, m := range catalog.Decorate(catalog.Chat(ms), cmdGoModels) {
if !cmdGoRefused[m.ID] {
out = append(out, m)
}
}
if len(out) == 0 {
return nil, errors.New("Command Code listed no models for the Go plan")
}
cmdMarkFree(out)
return out, catalog.SaveLive(CommandCodePlanID, base, out)
}
// cmdFree are the models Command Code's CLI marks FREE in its picker
// (1.73.2: badge:"free", "{name} is free and uses shared capacity"); its
// API's list doesn't say so, and Space Bunny Alpha's and Pixel Canary's
// ids name nothing free. Neither says a model is served at a discount.
var cmdFree = map[string]bool{
"stealth/space-bunny-alpha": true, "stealth/pixel-canary": true,
"poolside/laguna-s-2.1-free": true, "inclusionai/ling-3.0-flash-free": true,
"inclusionai/ling-3.0-flash-sante:free": true, "inclusionai/ling-3.1-flash:free": true,
"MiniMaxAI/MiniMax-M3-Free": true, "minimax/minimax-m3-free": true,
"minimax/minimax-m2.7-free": true, "meituan/LongCat-2.0:free": true, "tencent/Hy3": true,
}
// cmdMarkFree marks the models of ms the CLI calls free, in place.
func cmdMarkFree(ms []catalog.Model) []catalog.Model {
for i := range ms {
if cmdFree[ms[i].ID] {
ms[i].Free = true
}
}
return ms
}
func init() { cmdMarkFree(cmdGoModels) }
// cmdAuth is an account's key, as auth.json and the sign-in name it.
type cmdAuth struct {
APIKey string `json:"apiKey"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
KeyName string `json:"keyName,omitempty"`
}
// ---- the CLI's own account ----------------------------------------------------
func cmdAuthPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".commandcode", "auth.json")
}
// cmdOwn is the account Command Code's CLI is signed in to; ok is false
// when it has none.
func cmdOwn() (who string, a cmdAuth, ok bool) {
if !readJSON(cmdAuthPath(), &a) || strings.TrimSpace(a.APIKey) == "" {
return "", cmdAuth{}, false
}
return cmdWho(a), a, true
}
// cmdWho names an account: its user name, or its id.
func cmdWho(a cmdAuth) string {
return firstNonEmpty(a.UserName, a.UserID, "Command Code")
}
// ---- the accounts -------------------------------------------------------------
type cmdLogin struct {
Login
auth cmdAuth
}
func cmdSaved(l savedLogin) (cmdAuth, bool) {
var a cmdAuth
if json.Unmarshal(l.Auth, &a) != nil || a.APIKey == "" {
return cmdAuth{}, false
}
return a, true
}
// cmdLogins is every Command Code account signed in, the first in use first.
func cmdLogins() []cmdLogin {
ownUser, own, hasOwn := cmdOwn()
if !hasOwn {
ownUser = ""
}
var out []cmdLogin
for _, l := range sideLogins(CommandCodePlanID, ownUser, func(l savedLogin) bool {
_, ok := cmdSaved(l)
return ok
}) {
a := own
if !l.saved.own() {
a, _ = cmdSaved(l.saved)
}
out = append(out, cmdLogin{l.Login, a})
}
return out
}
func cmdSide() []sideLogin {
var out []sideLogin
for _, l := range cmdLogins() {
out = append(out, sideLogin{Login: l.Login})
}
return out
}
func cmdLoginList() []Login { return loginsOf(cmdSide()) }
func switchCommandCodeLogin(user string) error {
return switchSideLogin(CommandCodePlanID, user, cmdSide())
}
func setCommandCodeLoginOn(user string, on bool) error {
return setSideLoginOn(CommandCodePlanID, user, on, cmdSide())
}
func forgetCommandCodeLogin(user string) error {
return forgetSideLogin(CommandCodePlanID, user, cmdSide(), nil)
}
func commandCodeAccount() (Provider, bool) {
ls := cmdLogins()
if len(ls) == 0 {
return Provider{}, false
}
return cmdProvider(ls[0].User, ls[0].Plan, ls[0].auth), true
}
// commandCodeAlsoOn is the Command Code accounts in use behind the first.
func commandCodeAlsoOn() []Provider {
var out []Provider
for _, l := range cmdLogins() {
if !l.Active && l.On {
out = append(out, cmdProvider(l.User, l.Plan, l.auth))
}
}
return out
}
func cmdProvider(who, plan string, a cmdAuth) Provider {
p := Provider{ID: CommandCodePlanID, Name: "Command Code Plan", Icon: "commandcode",
Chat: cmdAPI + "/provider/v1", Responses: cmdAPI + "/provider/v1", Anthropic: cmdAPI + "/provider",
Website: cmdStudio}
acct := &Account{Agent: CommandCodePlanID, User: who, Plan: cmdPlanKnown(a, plan)}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
req.Header.Del("Authorization")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
req.Header.Set("x-api-key", a.APIKey)
return nil
}
acct.models = func() []catalog.Model {
if cmdPlanKnown(a, plan) == "Go" {
return cmdGoModels
}
return cmdModels
}
// the plan's list is the Provider API's, with what each model is
// served on; it is asked as a keyed provider's is. Go's key has no
// Provider API: its list is the same one, asked without it, less what
// Go is refused.
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
if cmdPlanNow(ctx, a, plan) == "Go" {
return cmdGoFetch(ctx)
}
keyed := p
keyed.Account, keyed.Key = nil, a.APIKey
ms, base, err := keyed.fetchOne(keyed.Via(ctx))
if err != nil {
return nil, err
}
cmdMarkFree(ms)
return catalog.Chat(ms), catalog.SaveLive(p.ID, base, ms)
}
acct.generate = func(ctx context.Context) (string, bool) {
return a.APIKey, cmdPlanNow(ctx, a, plan) == "Go"
}
p.Account = acct
return p
}
// CommandCodeGenerate says whether p is a Command Code account on the Go
// plan, which is asked at api+"/alpha/generate" in the CLI's own format
// rather than on the Provider API, with key; ok is false for every other
// account and provider.
func CommandCodeGenerate(ctx context.Context, p Provider) (api, key string, ok bool) {
if p.Account == nil || p.Account.generate == nil {
return "", "", false
}
if key, ok = p.Account.generate(ctx); !ok {
return "", "", false
}
return cmdAPI, key, true
}
// ---- which plan ---------------------------------------------------------------
// cmdPlansSeen is each key's plan, as billing/subscriptions last said
// ("" when it couldn't be read), and when.
var cmdPlansSeen = struct {
sync.Mutex
m map[string]cmdSeen
}{m: map[string]cmdSeen{}}
type cmdSeen struct {
plan string
at time.Time
}
// How long a plan read is kept, and a failure to read it before it is
// asked again.
var (
cmdPlanKeep = 10 * time.Minute
cmdPlanRetry = time.Minute
)
// cmdRemember keeps what billing/subscriptions said of a key's plan.
func cmdRemember(key, plan string) {
cmdPlansSeen.Lock()
cmdPlansSeen.m[key] = cmdSeen{plan, time.Now()}
cmdPlansSeen.Unlock()
}
// cmdPlanKnown is the account's plan as last read, else saved (the one
// its sign-in said), asking no one.
func cmdPlanKnown(a cmdAuth, saved string) string {
cmdPlansSeen.Lock()
seen := cmdPlansSeen.m[a.APIKey]
cmdPlansSeen.Unlock()
return firstNonEmpty(seen.plan, saved)
}
// cmdPlanNow is the account's plan, read again once what was read is
// older than cmdPlanKeep; saved while it can't be read. The CLI's own
// account has none saved, so it is always asked.
func cmdPlanNow(ctx context.Context, a cmdAuth, saved string) string {
cmdPlansSeen.Lock()
seen, ok := cmdPlansSeen.m[a.APIKey]
cmdPlansSeen.Unlock()
switch {
case ok && seen.plan != "" && time.Since(seen.at) < cmdPlanKeep:
return seen.plan
case ok && seen.plan == "" && time.Since(seen.at) < cmdPlanRetry:
return saved
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, plan, _, _, read := cmdSubscription(ctx, a)
if !read {
plan = ""
}
cmdRemember(a.APIKey, plan)
return firstNonEmpty(plan, saved)
}
// ---- allowance ----------------------------------------------------------------
// cmdPlans names the plans as the CLI does, by their id's start, longest
// first, with the dollars of credits each gives a month (its getPlanInfo;
// "individual-pro-v1" is the old Pro, $80 of them).
var cmdPlans = []struct {
id, name string
monthly float64
}{
{"individual-provider", "Provider", 15}, {"individual-pro-v1", "Pro", 80}, {"individual-goat", "GOAT", 70},
{"individual-ultra", "Ultra", 300}, {"individual-max", "Max", 150}, {"individual-pro", "Pro", 30},
{"individual-go", "Go", 10}, {"teams-pro", "Teams Pro", 40},
}
func cmdPlanOf(id string) (name string, monthly float64) {
id = strings.ReplaceAll(strings.ToLower(id), "_", "-")
for _, p := range cmdPlans {
if strings.HasPrefix(id, p.id) {
return p.name, p.monthly
}
}
return "", 0
}
func cmdPlanName(id string) string {
name, _ := cmdPlanOf(id)
return name
}
// cmdCredits is /alpha/billing/credits as the CLI's /usage reads it
// (projectUsageView): the dollars left of the month's credits, and of the
// bought and free ones beside them, under "credits"; the plan's windows
// next to it, not in it —
//
// {"credits":{"planId":"individual-goat-monthly","monthlyCredits":41.2,
// "purchasedCredits":5,"freeCredits":0},
// "windowLimits":{"limited":true,
// "fiveHour":{"used":3.1,"cap":10,"resetAt":1790000000000},
// "weekly":{"used":12,"cap":40,"resetAt":1790400000000}},
// "sandboxMinutes":{…},"sandboxAccess":false}
//
// resetAt is in milliseconds (the CLI compares it with Date.now()); used
// and cap are only ever divided, one by the other.
type cmdWindow struct {
Used any `json:"used"`
Cap any `json:"cap"`
ResetAt any `json:"resetAt"` // unix ms (seconds or a time taken too)
}
type cmdCredits struct {
Credits struct {
PlanID string `json:"planId"`
Monthly any `json:"monthlyCredits"`
Purchased any `json:"purchasedCredits"`
Free any `json:"freeCredits"`
} `json:"credits"`
Windows *struct {
FiveHour *cmdWindow `json:"fiveHour"`
Weekly *cmdWindow `json:"weekly"`
} `json:"windowLimits"`
}
// cmdTime reads a reset time: unix seconds or milliseconds, or RFC 3339.
func cmdTime(v any) *time.Time {
if s, ok := v.(string); ok {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return &t
}
}
n, ok := number(v)
if !ok || n <= 0 {
return nil
}
if n < 1e12 {
n *= 1000
}
t := time.UnixMilli(int64(n))
return &t
}
// cmdWindows is the plan's 5-hour and weekly windows in a credits reply,
// those it gives a cap.
func cmdWindows(c cmdCredits) []QuotaWindow {
out := []QuotaWindow{}
if c.Windows == nil {
return out
}
for _, w := range []struct {
name string
span time.Duration
w *cmdWindow
}{{"5 hours", 5 * time.Hour, c.Windows.FiveHour}, {"Weekly", 7 * 24 * time.Hour, c.Windows.Weekly}} {
if w.w == nil {
continue
}
used, ok1 := number(w.w.Used)
limit, ok2 := number(w.w.Cap)
if !ok1 || !ok2 || limit <= 0 {
continue
}
out = append(out, QuotaWindow{Name: w.name, Used: min(100, 100*max(0, used)/limit), Span: w.span,
ResetsAt: cmdTime(w.w.ResetAt)})
}
return out
}
// cmdLeft is the dollars left on the account, the month's credits and the
// bought and free ones together (the CLI's totalRemaining), and the
// month's alone; ok is false when the reply tells none of them.
func cmdLeft(c cmdCredits) (monthly, left float64, ok bool) {
for i, v := range []any{c.Credits.Monthly, c.Credits.Purchased, c.Credits.Free} {
if n, is := number(v); is {
n = max(0, n)
if i == 0 {
monthly = n
}
left += n
ok = true
}
}
return monthly, left, ok
}
// cmdQuotaOf makes the credits reply into the plan's windows: the 5-hour
// and weekly limits, and the month's credits as one more. A card's
// Balance is shown instead of its windows — it is a key's money, not an
// allowance — so, set beside them, it hid both limits on the Usage page,
// the panel and the TUI. The dollars are a window when the plan is known,
// used of the CLI's pool (the plan's month of credits, or what is left of
// it if more, and the bought and free ones) as its /usage bar is; a reply
// with neither windows nor a plan is a Balance still.
func cmdQuotaOf(q SubscriptionQuota, c cmdCredits) SubscriptionQuota {
name, planMonthly := cmdPlanOf(c.Credits.PlanID)
if name != "" {
q.Plan = name
}
q.Windows = cmdWindows(c)
monthly, left, known := cmdLeft(c)
switch {
case !known:
case planMonthly > 0:
pool := max(planMonthly, monthly) + left - monthly
q.Windows = append(q.Windows, QuotaWindow{Name: "Credits", Used: min(100, 100*(pool-left)/pool),
Display: money("$", pool-left) + " / " + money("$", pool)})
case len(q.Windows) == 0:
q.Balance = money("$", left)
}
return q
}
func cmdQuota(ctx context.Context, l Login, a cmdAuth) SubscriptionQuota {
q := SubscriptionQuota{Provider: CommandCodePlanID, Name: "Command Code", Icon: "commandcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
// the plan is said even while the credits can't be read (Command Code
// answers 503, "Couldn't verify your credit balance just now", at times)
id, plan, until, renew, planOK := cmdSubscription(ctx, a)
var c cmdCredits
if err := accountJSON(ctx, cmdAPI+"/alpha/billing/credits", a.APIKey, nil, &c); err != nil {
q.Error = err.Error()
} else {
// billing/credits leaves the plan out: the month of credits the pool
// is made of is the subscription's plan's
if c.Credits.PlanID == "" {
c.Credits.PlanID = id
}
q = cmdQuotaOf(q, c)
}
if planOK {
q.Plan, q.Until, q.Renew = plan, until, renew
cmdRemember(a.APIKey, plan)
}
return q
}
// cmdNoPlan is the plan of an account with no subscription: its key is
// billed against the credits it bought, if any.
const cmdNoPlan = "No plan"
// cmdSubscription is the account's plan (its id, and its name), when its period ends and whether
// it renews then, as billing/subscriptions says; cmdNoPlan when it has
// none. ok is false when that can't be read.
func cmdSubscription(ctx context.Context, a cmdAuth) (id, plan string, until *time.Time, renew string, ok bool) {
var r struct {
Success *bool `json:"success"` // false when Command Code couldn't tell ("write CONNECTION_CLOSED …"), though a 200
Data *struct {
PlanID string `json:"planId"`
Status string `json:"status"`
CurrentPeriodEnd any `json:"currentPeriodEnd"`
CancelAtEnd *bool `json:"cancelAtPeriodEnd"`
} `json:"data"`
}
if accountJSON(ctx, cmdAPI+"/alpha/billing/subscriptions", a.APIKey, nil, &r) != nil || (r.Success != nil && !*r.Success) {
return "", "", nil, "", false
}
if d := r.Data; d != nil && d.PlanID != "" && d.Status != "canceled" && d.Status != "incomplete_expired" {
if d.CancelAtEnd != nil {
renew = map[bool]string{true: "off", false: "auto"}[*d.CancelAtEnd]
}
return d.PlanID, firstNonEmpty(cmdPlanName(d.PlanID), d.PlanID), cmdTime(d.CurrentPeriodEnd), renew, true
}
return "", cmdNoPlan, nil, "", true
}
func cmdLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
for _, c := range cmdLogins() {
if strings.EqualFold(c.User, l.User) {
return cmdQuota(ctx, l, c.auth)
}
}
return SubscriptionQuota{Provider: CommandCodePlanID, Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
// ---- signing in ---------------------------------------------------------------
// startCommandCodeSignIn is the CLI's browser sign-in: Studio asks the
// user to approve a key for this machine, then posts it (a form, or JSON
// from an older Studio) to the callback, which sends the browser on to a
// page saying it is done.
func startCommandCodeSignIn(s *signInFlow) error {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
port := ln.Addr().(*net.TCPAddr).Port
q := url.Values{}
q.Set("callback", fmt.Sprintf("http://127.0.0.1:%d/callback", port))
q.Set("state", s.state)
q.Set("mode", "redirect")
srv := &http.Server{Handler: http.HandlerFunc(s.commandCodeCallback), ReadHeaderTimeout: 10 * time.Second}
s.mu.Lock()
s.st.URL = cmdStudio + "/studio/auth/cli?" + q.Encode()
// Studio's post can't reach a magpie on a server or in Docker: a key
// made on its keys page and pasted finishes it instead
s.st.PasteKey, s.st.KeysURL = true, cmdKeysURL
s.srv = srv
s.mu.Unlock()
go func() { _ = srv.Serve(ln) }()
return nil
}
// cmdWhoamiWait is how long a sign-in waits to ask whoami again.
var cmdWhoamiWait = time.Second
// cmdOrigins are the Studio pages that may post to the callback.
var cmdOrigins = []string{"https://commandcode.ai", "https://staging.commandcode.ai"}
func (s *signInFlow) commandCodeCallback(w http.ResponseWriter, r *http.Request) {
origin := cmdOrigins[0]
for _, o := range cmdOrigins {
if r.Header.Get("Origin") == o {
origin = o
}
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
switch {
case r.Method == http.MethodOptions:
w.WriteHeader(http.StatusNoContent)
return
case r.URL.Path == "/callback/complete" && r.Method == http.MethodGet:
st := s.status()
if st.State == "done" {
signInPage(w, true, "You're signed in", fmt.Sprintf("%s is added to magpie. You can close this tab.", st.User))
} else {
signInPage(w, false, "Sign-in didn't finish", firstNonEmpty(st.Error, "Start it again from magpie."))
}
return
case r.URL.Path == "/cancel":
s.finish(SignInState{State: "canceled"})
w.WriteHeader(http.StatusNoContent)
return
case r.URL.Path != "/callback":
http.NotFound(w, r)
return
case r.Method != http.MethodPost:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 10_000))
var got struct {
cmdAuth
State string `json:"state"`
Error string `json:"error"`
Desc string `json:"error_description"`
}
isJSON := strings.HasPrefix(strings.TrimSpace(strings.ToLower(r.Header.Get("Content-Type"))), "application/json")
if isJSON {
_ = json.Unmarshal(body, &got)
} else {
f, _ := url.ParseQuery(string(body))
got.APIKey, got.UserID, got.UserName, got.KeyName = f.Get("apiKey"), f.Get("userId"), f.Get("userName"), f.Get("keyName")
got.State, got.Error, got.Desc = f.Get("state"), f.Get("error"), f.Get("error_description")
}
answer := func(ok bool, msg string) {
if isJSON {
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusBadRequest)
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": ok, "error": msg})
return
}
// as the CLI answers: on to the page that says how it went
w.Header().Set("Location", "/callback/complete?state="+url.QueryEscape(got.State))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusSeeOther)
}
if got.State != s.state {
// not ours: someone else's page, or a stale tab
w.WriteHeader(http.StatusForbidden)
signInPage(w, false, "This link isn't from magpie's sign-in", "Start it again from magpie.")
return
}
if s.status().State != "waiting" {
answer(false, "this sign-in is over")
return
}
if got.Error != "" {
msg := firstNonEmpty(got.Desc, got.Error)
if got.Error == "access_denied" {
msg = "the sign-in was denied"
}
s.finish(SignInState{State: "failed", Error: msg})
answer(false, msg)
return
}
if got.APIKey == "" {
answer(false, "Command Code sent back no key")
return
}
if !s.claim() {
// a key was pasted too, and that one is being kept
answer(false, "this sign-in is already finishing")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// the browser comes back for its page after this: the server stays up
// a while for it, where finish would close it within a second
if err := s.commandCodeKeep(ctx, got.cmdAuth, true); err != nil {
answer(false, err.Error())
return
}
answer(true, "")
}
// commandCodeKeep names the account a key is Command Code's, keeps it beside
// the others, and finishes the sign-in. keepServer leaves the callback
// server up a while, for the browser to come back for its page.
func (s *signInFlow) commandCodeKeep(ctx context.Context, a cmdAuth, keepServer bool) error {
who, plan, err := cmdSignedIn(ctx, a)
if err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
return err
}
a.UserName = who
auth, _ := json.Marshal(a)
ownUser, _, hasOwn := cmdOwn()
if !hasOwn {
ownUser = ""
}
if err := addSideLogin(savedLogin{Agent: CommandCodePlanID, User: who, Plan: plan, Auth: auth}, ownUser, func(savedLogin) {}); err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
return err
}
if keepServer {
s.mu.Lock()
srv := s.srv
s.srv = nil
s.mu.Unlock()
if srv != nil {
time.AfterFunc(10*time.Second, func() { _ = srv.Close() })
}
}
s.finish(SignInState{State: "done", User: who, Plan: plan, Using: hasOwn && strings.EqualFold(ownUser, who)})
return nil
}
// cmdKeysURL is Studio's page of API keys, where one is made to paste.
var cmdKeysURL = "https://commandcode.ai/settings/keys"
// commandCodeKey finishes a sign-in with a key pasted from Studio's keys
// page, as the CLI takes one ("Authorize in browser, or paste API key
// here"). It is how a magpie the browser can't reach — on a server, in
// Docker — signs in: Studio posts the key to the callback on 127.0.0.1 in
// the background, so the page the browser ends on has nothing in its
// address to paste.
func (s *signInFlow) commandCodeKey(raw string) error {
key := strings.TrimSpace(raw)
switch {
case key == "":
return errors.New("paste an API key from Command Code's keys page")
case strings.Contains(key, "://"):
return errors.New("that's an address: Command Code sends its key in the background, so make a key on its keys page and paste that")
case strings.ContainsAny(key, " \t\r\n"):
return errors.New("that doesn't look like a Command Code API key")
}
if s.status().State != "waiting" {
return errors.New("this sign-in is over; start it again")
}
if !s.claim() {
return errors.New("this sign-in is already finishing")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return s.commandCodeKeep(ctx, cmdAuth{APIKey: key}, false)
}
// cmdSignedIn names a new key's account with whoami, and reads its plan.
// Only whoami turning the key down (401, 403) fails the sign-in: the CLI's
// own browser sign-in keeps the key Studio posts without asking whoami at
// all, and whoami has answered a key just made with a 500, so a whoami
// that errs is asked again a few times and then passed over for the name
// Studio sent with the key.
func cmdSignedIn(ctx context.Context, a cmdAuth) (who, plan string, err error) {
var me struct {
User struct {
ID string `json:"id"`
UserName string `json:"userName"`
Name string `json:"name"`
Email string `json:"email"`
} `json:"user"`
}
for try := 0; ; try++ {
err := accountJSON(ctx, cmdAPI+"/alpha/whoami", a.APIKey, nil, &me)
var st *accountStatusError
if err == nil {
break
}
if errors.As(err, &st) && (st.status == http.StatusUnauthorized || st.status == http.StatusForbidden) {
return "", "", fmt.Errorf("Command Code didn't take the new key: %w", err)
}
if try == 2 || ctx.Err() != nil {
if firstNonEmpty(a.UserName, a.UserID) == "" {
return "", "", fmt.Errorf("Command Code couldn't say which account signed in: %w", err)
}
break
}
select {
case <-ctx.Done():
case <-time.After(cmdWhoamiWait):
}
}
who = firstNonEmpty(me.User.UserName, a.UserName, me.User.Email, me.User.ID, a.UserID)
if who == "" {
return "", "", fmt.Errorf("Command Code didn't say which account signed in")
}
if _, p, _, _, ok := cmdSubscription(ctx, a); ok {
plan = p
}
return who, plan, nil
}