Files
magpie/internal/provider/commandcode_plan.go
T
yetone a08b061b2f commandcode: a Command Code Go-plan account is served through the gateway in the CLI's own format, at /alpha/generate (Discord #general, Mikan: 有计划支持command code的go plan吗,走他们的私有格式) — Go has no Provider API access, so magpie's Command Code Plan subscription, which asks api.commandcode.ai/provider, could not serve a Go account. Read from command-code 1.72.2's bundle: the CLI asks POST /alpha/generate for the models Command Code hosts, with {config, memory:null, taste:null, skills:null, permissionMode, params:{model, messages, tools, system, max_tokens, stream, temperature?, reasoning_effort?}} (createModelClient, toWireMessages: text / tool-call / reasoning parts, tool results as role "tool" tool-result parts, images as data URLs), the headers of buildCommandAuthHeaders (User-Agent cli, x-command-code-version, x-cli-environment production, x-project-slug, x-taste-learning, x-session-id, Bearer key), and reads newline-delimited JSON back (text-delta, reasoning-delta, tool-call, finish with totalUsage, error, abort). Its plan table lets individual-go / individual-go-v1 use the "opensource" models less a blocked list. Now: an account's plan is read from /alpha/billing/subscriptions and kept 10 minutes per key (the Usage page's read refreshes it; a failed read falls back to the sign-in's plan and is retried after a minute; GOAT is not taken for Go). A Go account is asked at /alpha/generate, translated to and from Anthropic Messages, OpenAI chat and Responses, streamed or not, tool calls included, and lists Go's models from the CLI's table (GPT-6 Luna, GPT-5.6 Luna, DeepSeek V4 Pro/Flash, Kimi K3, GLM-5.3, MiniMax M3, Qwen 3.8 Max/Flash, MiMo V2.6 Pro); MODEL_NOT_IN_PLAN is a 403 and exhausted credits a 402, so routing moves on; token counts are estimated locally. Pro, Max, Ultra, GOAT and Teams accounts go on the Provider API as before. Tested with internal/gateway/commandcode_test.go and internal/provider/commandcode_go_test.go against a fake Command Code in a temp HOME: a Go account routed to /alpha/generate and a Pro one to /provider/v1/messages, the request body and headers, streamed Anthropic, chat and Responses (streamed and not) with text, reasoning, a tool call and usage, MODEL_NOT_IN_PLAN over HTTP and in the stream, credits run out, a stream error, a reply cut short, the plan cached, re-read and falling back; also under -race. They fail with the routing off. go vet, go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Go account: which models Go allows today, whether the server checks config, project slug or session id, the usage arithmetic with cache tokens, real errors' codes, and pause_turn (ended rather than resent).
2026-09-30 08:37:57 +08:00

705 lines
23 KiB
Go

package provider
// 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"
// 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.72.2): the "opensource" ones, less those it blocks for
// Go (gpt-5.6-sol, claude-sonnet-5-5, grok-4.6 and the like) —
//
// "individual-go":{allowedCategories:[zr],blockedModels:Qr=[…]}
//
// Command Code has no list to ask for them: a model the plan hasn't is
// refused with MODEL_NOT_IN_PLAN.
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: "moonshotai/Kimi-K3", Name: "Kimi K3", 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: "MiniMaxAI/MiniMax-M3", Name: "MiniMax M3", Context: 1_000_000},
{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-Flash", Name: "Qwen 3.8 Flash", Context: 1_000_000},
{ID: "xiaomi/mimo-v2.6-pro", Name: "MiMo V2.6 Pro", Context: 1_048_576, Images: true},
}
// 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 CLI's.
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
if cmdPlanNow(ctx, a, plan) == "Go" {
return cmdGoModels, nil
}
keyed := p
keyed.Account, keyed.Key = nil, a.APIKey
return keyed.Fetch(ctx)
}
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 {
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 {
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()
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
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
who, plan, err := cmdSignedIn(ctx, got.cmdAuth)
if err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
answer(false, err.Error())
return
}
a := got.cmdAuth
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()})
answer(false, err.Error())
return
}
// 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
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)})
answer(true, "")
}
// 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
}