Files
magpie/internal/gateway/commandcode.go
T
yetone b56bfb5a94 gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
2026-10-06 18:26:31 +08:00

530 lines
16 KiB
Go

package gateway
// 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 account on the Go plan has no Provider API: its key is
// only taken where the CLI itself asks, POST /alpha/generate. The request
// is written as the CLI (command-code 1.72.2) writes it — its
// createModelClient:
//
// {config:{workingDir,date,environment,structure,isGitRepo,…},memory:null,
// taste:null,skills:null,permissionMode:"standard",
// params:{model,messages,tools,system,max_tokens,stream:true,
// temperature?,reasoning_effort?}}
//
// the messages in the AI SDK's parts (toWireMessages): an assistant's
// text, tool-call and reasoning; a user's tool results as a message of
// role "tool" before its text and images. The reply is a JSON object a
// line (readLines, consumeStream): text-delta, reasoning-delta, tool-call
// with its input whole, finish with the usage, error. The headers are
// buildCommandAuthHeaders'. Every other plan is served on the Provider
// API, as before (provider/commandcode_plan.go).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/provider"
)
// cmdGenerate says whether an account is asked at /alpha/generate, where,
// and with which key; a var so tests can stand in for it.
var cmdGenerate = provider.CommandCodeGenerate
// cmdCLIVersion is the CLI the requests say they come from.
const cmdCLIVersion = "1.72.2"
// cmdMaxTokens is the CLI's max_tokens when none is asked (Hk=64e3).
const cmdMaxTokens = 64000
// cmdSession is the x-session-id magpie's requests carry: one for as long
// as magpie runs, as one CLI session has one.
var cmdSession = sync.OnceValue(newUUID)
// cmdGoing says whether p is a Go account, asked at /alpha/generate.
func cmdGoing(ctx context.Context, p provider.Provider) bool {
_, _, ok := cmdGenerate(p.Via(ctx), p)
return ok
}
// serveCommandCode answers a request through /alpha/generate, for a Go
// account.
func (s *Server) serveCommandCode(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, api, key, model string, body []byte, usage *Usage) (int, string) {
req, err := parse(from, body)
if err != nil {
return writeError(w, from, 400, err.Error()), err.Error()
}
req.Model = model
if req.Effort != "" && !req.ThinkOff {
req.Effort = fitEffort(req.Effort, p.Efforts(model))
}
ask := s.askCommandCode(api, key, model)
if req.WebSearch && !searching(r.Context()) {
if canSearch() {
return s.searchReply(w, r, from, "Command Code", req, usage, ask)
}
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
events, status, msg := ask(ctx, req)
if events == nil {
return writeError(w, from, status, msg), msg
}
// its failures keep their statuses (out of credits is another
// account's turn), and a reply cut short is an error
return relayStatus(w, from, "Command Code", req, events, usage, cancel)
}
// ---- the request ----------------------------------------------------------
type cmdPart struct {
Type string `json:"type"`
Text *string `json:"text,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ToolName string `json:"toolName,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Output *cmdOutput `json:"output,omitempty"`
Image string `json:"image,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
type cmdOutput struct {
Type string `json:"type"`
Value string `json:"value"`
}
type cmdMessage struct {
Role string `json:"role"`
Content []cmdPart `json:"content"`
}
type cmdTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
func cmdText(kind, s string) cmdPart { return cmdPart{Type: kind, Text: &s} }
// cmdImage is an image as the CLI sends one: a data URL, and its type.
func cmdImage(p Part) (cmdPart, bool) {
mt := p.MediaType
if mt == "" {
mt = "image/png"
}
switch {
case p.Data != "":
return cmdPart{Type: "image", Image: "data:" + mt + ";base64," + p.Data, MimeType: mt}, true
case p.URL != "":
return cmdPart{Type: "image", Image: p.URL, MimeType: p.MediaType}, true
}
return cmdPart{}, false
}
// cmdMessages is the conversation in the CLI's wire parts.
func cmdMessages(req *Request) []cmdMessage {
out := []cmdMessage{}
names := map[string]string{} // a tool call's id → its tool
for _, m := range req.Messages {
if m.Role == "assistant" {
var parts []cmdPart
for _, p := range m.Parts {
switch p.Kind {
case Text:
if p.Text != "" {
parts = append(parts, cmdText("text", p.Text))
}
case ToolCall:
names[p.ID] = p.Name
parts = append(parts, cmdPart{Type: "tool-call", ToolCallID: p.ID, ToolName: p.Name, Input: argsOf(p)})
case Thinking:
if p.Text != "" {
parts = append(parts, cmdText("reasoning", p.Text))
}
}
}
if len(parts) > 0 {
out = append(out, cmdMessage{"assistant", parts})
}
continue
}
var results, said []cmdPart
for _, p := range m.Parts {
switch p.Kind {
case ToolResult:
name := names[p.CallID]
if name == "" {
name = "unknown"
}
results = append(results, cmdPart{Type: "tool-result", ToolCallID: p.CallID, ToolName: name,
Output: &cmdOutput{Type: "text", Value: p.Text}})
// the CLI's tool results are text alone; the images a tool
// returned go with the user's turn after them
for _, im := range p.Images {
if c, ok := cmdImage(im); ok {
said = append(said, c)
}
}
case Text:
if p.Text != "" {
said = append(said, cmdText("text", p.Text))
}
case Image:
if c, ok := cmdImage(p); ok {
said = append(said, c)
}
case File:
if p.Text != "" {
said = append(said, cmdText("text", p.Text))
}
}
}
if len(results) > 0 {
out = append(out, cmdMessage{"tool", results})
}
if len(said) > 0 {
out = append(out, cmdMessage{"user", said})
}
}
return out
}
// cmdPlatform is the machine as Node names it (process.platform).
func cmdPlatform() string {
if runtime.GOOS == "windows" {
return "win32"
}
return runtime.GOOS
}
// cmdRequest is req as the CLI asks /alpha/generate. There is no project
// behind a request through magpie: the config is the CLI's outside a git
// repository (buildServerConfig), in the user's home.
func cmdRequest(req *Request, model string) []byte {
tools := []cmdTool{}
for _, t := range req.Tools {
schema := t.Schema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
tools = append(tools, cmdTool{t.Name, t.Description, schema})
}
params := map[string]any{
"model": model, "messages": cmdMessages(req), "tools": tools, "system": req.System,
"max_tokens": cmdMaxTokens, "stream": true,
}
if req.MaxTokens > 0 {
// within what the model gives and Command Code takes: more is
// refused, the whole request with it
params["max_tokens"] = min(req.MaxTokens, provider.CommandCodeOutputOf(model))
}
if req.Temp != nil {
params["temperature"] = *req.Temp
}
if req.Effort != "" && !req.ThinkOff {
params["reasoning_effort"] = req.Effort
}
home, _ := os.UserHomeDir()
b, _ := json.Marshal(map[string]any{
"config": map[string]any{
"workingDir": home, "date": time.Now().Format("2006-01-02"), "environment": cmdPlatform(),
"structure": []string{}, "isGitRepo": false, "currentBranch": "", "mainBranch": "", "gitStatus": "",
"recentCommits": []string{},
},
"memory": nil, "taste": nil, "skills": nil,
"permissionMode": "standard",
"params": params,
})
return b
}
// cmdHeaders are the CLI's own (buildCommandAuthHeaders).
func cmdHeaders(h http.Header, key string) {
h.Set("Content-Type", "application/json")
h.Set("User-Agent", "cli")
h.Set("x-command-code-version", cmdCLIVersion)
h.Set("x-cli-environment", "production")
h.Set("x-project-slug", "magpie")
h.Set("x-taste-learning", "false")
h.Set("x-session-id", cmdSession())
h.Set("Authorization", "Bearer "+key)
}
// ---- the reply ------------------------------------------------------------
// askCommandCode is a round for /alpha/generate.
func (s *Server) askCommandCode(api, key, model string) round {
return func(ctx context.Context, req *Request) (<-chan Event, int, string) {
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(api, "/")+"/alpha/generate", bytes.NewReader(cmdRequest(req, model)))
if err != nil {
return nil, 500, "Command Code: " + err.Error()
}
cmdHeaders(hr.Header, key)
res, err := s.client.Do(hr)
if err != nil {
return nil, 502, "Command Code: " + err.Error()
}
res = notAnAPIReply(res, "") // a web page served 200 (#1012)
if res.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
status, msg := cmdFailure(res.StatusCode, b)
return nil, status, msg
}
events := make(chan Event, 16)
go func() {
defer close(events)
defer res.Body.Close()
decodeCommandCode(ctx, res.Body, events, model)
}()
return events, 0, ""
}
}
// cmdFailure is the status and message for a failure, from Command
// Code's {"error":{"type","message"}} (parseEmbeddedErrorJSON) or its
// text: a model the plan hasn't is a 403, as another account's plan may
// have it; credits run out a 402, a window's limit a 429.
func cmdFailure(status int, b []byte) (int, string) {
msg := strings.TrimSpace(firstString(gjson.GetBytes(b, "error.message").String(), gjson.GetBytes(b, "message").String(),
gjson.GetBytes(b, "error").String(), string(b)))
if msg == "" {
msg = http.StatusText(status)
}
if len(msg) > 600 {
msg = msg[:600]
}
low := strings.ToLower(msg)
switch {
case strings.Contains(msg, "MODEL_NOT_IN_PLAN:"):
status = 403
msg = "Model not in plan: " + strings.TrimSpace(strings.Replace(msg, "MODEL_NOT_IN_PLAN:", "", 1))
case strings.Contains(low, "model_not_in_plan"):
status = 403
case strings.Contains(low, "premium_credits_exhausted"), strings.Contains(low, "insufficient credits"):
status = 402
msg = strings.TrimSpace(strings.Replace(msg, "PREMIUM_CREDITS_EXHAUSTED:", "", 1))
if !quotaWords.MatchString(msg) {
msg = "out of credits: " + msg
}
case strings.Contains(msg, "RATE_LIMITED"), strings.Contains(low, "usage limit"), strings.Contains(low, "window_limit"):
if status < 400 || status == 500 {
status = 429
}
}
if status < 400 || status > 599 {
status = 502
}
return status, "Command Code: " + msg
}
func firstString(ss ...string) string {
for _, s := range ss {
if strings.TrimSpace(s) != "" {
return s
}
}
return ""
}
// cmdLine is one line of the reply, the AI SDK's stream parts as the
// server passes them on.
type cmdLine struct {
Type string `json:"type"`
Text string `json:"text"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
Input json.RawMessage `json:"input"`
Args json.RawMessage `json:"args"`
ProviderExecuted bool `json:"providerExecuted"`
FinishReason string `json:"finishReason"`
RawFinishReason string `json:"rawFinishReason"`
TotalUsage *struct {
Input int `json:"inputTokens"`
Output int `json:"outputTokens"`
Details *struct {
NoCache *int `json:"noCacheTokens"`
CacheRead int `json:"cacheReadTokens"`
CacheWrite int `json:"cacheWriteTokens"`
} `json:"inputTokenDetails"`
OutDetails *struct {
Reasoning int `json:"reasoningTokens"`
} `json:"outputTokenDetails"`
} `json:"totalUsage"`
CacheWriteTokens int `json:"cacheWriteTokens"`
Error json.RawMessage `json:"error"`
}
// cmdInput is a tool call's input: an object, or the object as a string.
func cmdInput(l cmdLine) json.RawMessage {
raw := l.Input
if len(raw) == 0 || string(raw) == "null" {
raw = l.Args
}
var s string
if json.Unmarshal(raw, &s) == nil {
return parseArgs(s)
}
if len(raw) == 0 || string(raw) == "null" {
return json.RawMessage("{}")
}
return parseArgs(string(raw))
}
// cmdStreamError is an error line's message and status: a string, or
// {message, statusCode}, whose message may hold the HTTP error it was.
func cmdStreamError(raw json.RawMessage) (int, string) {
e := gjson.ParseBytes(raw)
msg, status := e.String(), 0
if e.IsObject() {
msg, status = e.Get("message").String(), int(e.Get("statusCode").Int())
}
if msg == "" {
msg = "Stream error"
}
// "429 {"error":{…}}", as parseEmbeddedErrorJSON reads it
if i := strings.Index(msg, "{"); i >= 0 && gjson.Valid(msg[i:]) {
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(msg[:i]), "%d", &n); err == nil && n >= 400 {
status = n
}
if m := gjson.Get(msg[i:], "error.message").String(); m != "" {
msg = m
}
}
if status == 0 {
status = 500
}
return cmdFailure(status, []byte(msg))
}
// decodeCommandCode turns the reply's lines into events. A reply with no
// finish line was cut short (the CLI's "stream ended without a finish
// event"): an error, not a shorter answer.
func decodeCommandCode(ctx context.Context, body io.Reader, out chan<- Event, model string) {
send := func(ev Event) bool {
select {
case out <- ev:
return true
case <-ctx.Done():
return false
}
}
if !send(Event{Kind: KStart, MsgID: "msg_" + randomToken()[:24], Model: model}) {
return
}
var u Usage
stop, tools, finished := "stop", 0, false
br := bufio.NewReaderSize(body, 64<<10)
for {
line, err := br.ReadString('\n')
if s := strings.TrimSpace(line); s != "" {
var l cmdLine
if json.Unmarshal([]byte(s), &l) == nil {
switch l.Type {
case "text-delta":
if l.Text != "" && !send(Event{Kind: KText, Text: l.Text}) {
return
}
case "reasoning-delta":
if l.Text != "" && !send(Event{Kind: KThink, Text: l.Text}) {
return
}
case "tool-call":
// one run by the server has its result beside it: the
// client has nothing to run
if l.ProviderExecuted {
break
}
tools++
id := l.ToolCallID
if id == "" {
id = "toolu_" + randomToken()[:24]
}
if !send(Event{Kind: KToolStart, ID: id, Name: l.ToolName}) ||
!send(Event{Kind: KToolArgs, Text: string(cmdInput(l))}) {
return
}
case "cache-write-tokens":
if l.CacheWriteTokens > u.CacheWrite {
u.CacheWrite = l.CacheWriteTokens
}
case "finish":
finished = true
if t := l.TotalUsage; t != nil {
u.Output = t.Output
input := t.Input
if d := t.Details; d != nil {
u.CacheRead = d.CacheRead
u.CacheWrite = max(u.CacheWrite, d.CacheWrite)
// the AI SDK's inputTokens counts the cache in
// it; magpie's Input, as Anthropic's, doesn't
if d.NoCache != nil {
input = *d.NoCache
} else {
input = max(0, input-d.CacheRead-d.CacheWrite)
}
}
u.Input = input
if t.OutDetails != nil {
u.Reasoning = t.OutDetails.Reasoning
}
}
switch l.FinishReason {
case "tool-calls":
stop = "tool"
case "length":
stop = "length"
case "content-filter":
stop = "filter"
}
case "abort":
finished = true
case "error":
status, msg := cmdStreamError(l.Error)
send(Event{Kind: KError, Status: status, Text: msg})
return
}
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
if ctx.Err() == nil {
send(Event{Kind: KError, Text: "Command Code: the reply broke off: " + err.Error()})
}
return
}
break
}
}
if !finished {
send(Event{Kind: KError, Text: "Command Code: the reply ended before it was complete"})
return
}
if tools > 0 && stop == "stop" {
stop = "tool"
}
send(Event{Kind: KUsage, Usage: u})
send(Event{Kind: KStop, Stop: stop})
}