Files
yetone 40c5b92a93 gateway: structured output crosses every translation: a Responses text.format or a Chat response_format reaches a Chat, Responses, Messages or Gemini upstream in its own words (frostming on Discord: responses.parse through magpie to OpenRouter came back as plain text)
The IR held a schema only from Anthropic's output_config.format, and only
Messages (and Claude Code's --json-schema) were asked for one. A Responses
request to a provider magpie speaks Chat or Messages to (OpenRouter has no
Responses URL) lost text.format; a Chat request translated to Responses or
Messages lost response_format; an Anthropic schema translated to Chat or
Responses was lost too.

Format (format.go) now carries json_schema (name, description, schema,
strict) or json_object from Responses, Chat, Anthropic and Gemini, and each
builder asks for it in its upstream's field. Where a target can't say it
(json_object on Messages or Claude Code; the vendor bridges Cursor, Devin,
Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream
answering 400/422 naming the field (DeepSeek's "This response_format type is
unavailable now") is asked again with it told in words, and so from then on.
Relayed requests are unchanged.
2026-10-08 10:37:02 +08:00

532 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()
}
// the vendor's own API is told the answer's format in words
req = req.inSystem()
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})
}