Files
magpie/internal/gateway/qoder.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

705 lines
21 KiB
Go

package gateway
// PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn")
// are deprecated built-in subscriptions served by their plugin,
// @magpie-community/opencode-qoder-auth, each 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/qoder) and raise the
// movers' min in internal/provider/migrate_qoder.go.
import (
"bufio"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"time"
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/qoder"
)
// A Qoder subscription is served through the API the Qoder client talks to
// (api3.qoder.sh agent_chat_generation SSE), signed with the COSY envelope and
// encoded with the client's body codec, the way a Devin one is (devin.go).
// Qoder exposes tool calls as XML in its text, so the request is given the
// matching contract and the reply is adapted back into tool-call events.
// The protocol lives in internal/qoder.
//
// The XML markers below are written with hex escapes for their angle brackets
// so this source holds no literal tag the tooling would mistake for a call.
const (
qoderCallOpen = "\x3ctool_call\x3e"
qoderCallClose = "\x3c/tool_call\x3e"
)
var (
qoderFunc = regexp.MustCompile(`(?s)\x3cfunction=([^>]+)\x3e(.*?)\x3c/function\x3e`)
qoderArg = regexp.MustCompile(`(?s)\x3cparameter=([^>]+)\x3e(.*?)\x3c/parameter\x3e`)
)
const qoderSys = "You are a Qoder agent. Use the instructions below and the tools available to you to assist the user."
// qoderAuth hands a request the signed-in account's uid and a live job
// token, and qoderURL the chat endpoint on the credential's site (qoder.com's
// or Qoder CN's); vars so tests can stand in for them, the way a Devin round
// stands in for devinAuth.
var (
qoderAuth = provider.QoderCredentialOf
qoderModel = provider.QoderModelOf
qoderURL = func(c *qoder.Credential) string { return c.OnSite().ChatURL() }
)
// serveQoder answers a request through Qoder's API.
func (s *Server) serveQoder(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, 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
ask := s.askQoder(p.Account.Agent, model, p.Account.User)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
q := req
var tool string
if req.WebSearch && !searching(r.Context()) {
if canSearch() {
copy := *req
copy.WebSearch = false
search := searchTool(copy.Tools)
copy.Tools = append(slices.Clone(copy.Tools), search)
copy.Messages = slices.Clone(copy.Messages)
q, tool = &copy, search.Name
}
}
events, status, msg := ask(ctx, q)
if events == nil {
return writeError(w, from, status, msg), msg
}
if tool != "" {
first := events
out := make(chan Event, 16)
go func() {
defer close(out)
s.searchRounds(ctx, q, tool, first, ask, out)
}()
events = out
}
return relayQoder(w, from, req, events, usage, cancel)
}
// relayQoder preserves upstream failures before committing a response, including
// failures after partial non-streaming text. Streaming failures after output
// has begun are terminal error events. Keep this policy to the backends that
// report their own statuses: Qoder's, and Zed's (relayStatus).
func relayQoder(w http.ResponseWriter, from provider.Protocol, req *Request, events <-chan Event, usage *Usage, abort context.CancelFunc) (int, string) {
return relayStatus(w, from, "Qoder", req, events, usage, abort)
}
// relayStatus is relayQoder for the backend called name.
func relayStatus(w http.ResponseWriter, from provider.Protocol, name string, req *Request, events <-chan Event, usage *Usage, abort context.CancelFunc) (int, string) {
fail := func(ev Event) (int, string) {
abort()
code := ev.Status
if code < 400 || code > 599 {
code = 502
}
return writeError(w, from, code, ev.Text), ev.Text
}
if req.Stream {
var head []Event
for ev := range events {
if ev.Kind == KError {
return fail(ev)
}
head = append(head, ev)
if ev.Kind != KStart && ev.Kind != KUsage {
break
}
}
if len(head) == 0 || head[len(head)-1].Kind == KStart || head[len(head)-1].Kind == KUsage {
return fail(Event{Text: name + " ended without an answer"})
}
sw := newSSEWriter(w)
enc := encoder(from, sw, req, usage)
var failed string
see := func(ev Event) bool {
if ev.Kind == KStart || ev.Kind == KUsage {
usage.add(ev.Usage)
}
enc.event(ev)
if ev.Kind == KError {
failed = ev.Text
return false
}
return true
}
for _, ev := range head {
see(ev)
}
// kept from the client's idle timeout while none comes (#436)
relayEvents(events, sw, enc, see)
if failed != "" {
abort()
return 200, failed
}
enc.finish()
return 200, ""
}
var col collector
for ev := range events {
if ev.Kind == KError {
return fail(ev)
}
col.add(ev)
}
res := col.finish()
usage.add(res.Usage)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write(renderUsage(from, res, req, usage))
return 200, ""
}
// askQoder is a round for Qoder's API, on the site agent ("qoder" or
// "qoder-cn") names.
func (s *Server) askQoder(agent, model, user string) round {
return func(ctx context.Context, req *Request) (<-chan Event, int, string) {
cred, err := qoderAuth(ctx, agent, user)
if err != nil {
// only a sign-in that is gone asks the agent to log in again;
// a refresh that timed out or never reached Qoder may pass
status := 502
if errors.Is(err, provider.ErrQoderSignIn) {
status = 401
}
return nil, status, "Qoder: " + err.Error()
}
config, err := qoderModel(ctx, agent, user, model)
if err != nil {
return nil, 400, err.Error()
}
plain, err := qoderChatBody(req, config)
if err != nil {
return nil, 500, "Qoder: " + err.Error()
}
wire := qoder.EncodeRequestBody(plain)
ts := time.Now().Unix()
chat := qoderURL(cred)
headers, err := qoder.BuildCosyHeaders(chat, &qoder.User{UID: cred.UID, Token: cred.Token,
Name: cred.Name, Email: cred.Email, MachineID: cred.MachineID}, wire, ts)
if err != nil {
return nil, 500, "Qoder: " + err.Error()
}
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, chat, strings.NewReader(wire))
if err != nil {
return nil, 500, "Qoder: " + err.Error()
}
for k, v := range headers {
hr.Header.Set(k, v)
}
hr.Header.Set("Accept", "text/event-stream")
hr.Header.Set("Cache-Control", "no-cache")
hr.Header.Set("Accept-Encoding", "identity")
hr.Header.Set("X-Model-Key", config.Key)
hr.Header.Set("X-Model-Source", config.Source)
res, err := s.client.Do(hr)
if err != nil {
return nil, 502, "Qoder: " + 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 := qoderFailure(res.StatusCode, b)
return nil, status, msg
}
events := make(chan Event, 32)
go decodeQoder(ctx, res, events, model)
return events, 0, ""
}
}
// qoderFailure maps a bad upstream status to magpie's status and message.
func qoderFailure(status int, body []byte) (int, string) {
if status < 400 || status > 599 {
status = 502
}
msg := strings.TrimSpace(string(body))
if gjson.ValidBytes(body) {
if m := gjson.GetBytes(body, "message").String(); m != "" {
msg = m
}
// the model vendor's own reason, e.g. an effort the model refuses
if d := gjson.Get(gjson.GetBytes(body, "details").String(), "error.message").String(); d != "" {
msg += ": " + d
}
}
if msg == "" {
msg = http.StatusText(status)
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return 401, "Qoder: the sign-in lapsed — sign in again"
case status == http.StatusTooManyRequests || strings.Contains(strings.ToLower(msg), "quota"):
return 429, "usage limit reached: " + msg
}
return status, "Qoder: " + msg
}
// qoderChatBody is the plaintext JSON Qoder's agent_chat_generation takes,
// built from magpie's IR request the way the reference does: messages as
// content blocks, tool calls and results as OpenAI tool turns, and the
// caller's tools beside them.
func qoderChatBody(req *Request, model qoder.ModelInfo) ([]byte, error) {
if model.Key == "" || len(model.Config) == 0 {
return nil, fmt.Errorf("Qoder: missing model configuration")
}
sys := map[string]any{"type": "text", "text": qoderSysText(req)}
all := []any{map[string]any{"role": "system", "content": []any{sys}}}
asked := req.Effort
if req.ThinkOff {
asked = "none" // Effort reads none as low
}
thinking, effort := qoderEffort(asked, model)
maxTok := int64(32000)
if req.MaxTokens > 0 {
maxTok = int64(req.MaxTokens)
}
params := map[string]any{"enable_thinking": thinking, "max_tokens": maxTok}
if effort != "" {
params["reasoning_effort"] = effort
}
if model.MaxInputTokens > 0 {
params["context_length"] = model.MaxInputTokens
}
body := map[string]any{
"parameters": params,
"business": map[string]any{
"product": "app", "version": "1.1.49", "type": "agent",
"id": qoder.NewID(), "name": "magpie session",
"begin_at": time.Now().UnixMilli(), "stage": "start",
},
"agent_id": "agent_common",
"task_id": "common",
"session_type": "app",
"model_config": model.Config,
"system": []any{sys},
"messages": append(all, qoderMessages(req.Messages)...),
}
if req.ToolChoice != "none" && len(req.Tools) > 0 {
body["tools"] = qoderTools(req.Tools)
}
return json.Marshal(body)
}
// qoderMessages renders magpie's turns as OpenAI turns, the format Qoder's
// chat endpoint takes: an assistant's tool calls as tool_calls and their
// results as tool turns named by tool_call_id, so a result is paired with
// its call and not by order. A tool turn holds text only, so the images a
// tool returned go after the tool turns in a user turn — as the start of
// the user's own turn when one comes next — the way buildChat sends them.
func qoderMessages(ms []Message) []any {
var out []any
names := map[string]string{}
var seen []any
seeLater := func(p Part) int {
var ims []any
for _, im := range p.Images {
if im.Data != "" {
ims = append(ims, map[string]any{"type": "image_url",
"image_url": map[string]any{"url": "data:" + im.MediaType + ";base64," + im.Data}})
}
}
if len(ims) == 0 {
return 0
}
of := "tool call " + p.CallID
if name := cmp.Or(names[p.CallID], p.Name); name != "" {
of = name + " (" + of + ")"
}
seen = append(seen, map[string]any{"type": "text", "text": "[From the result of " + of + ":]"})
seen = append(seen, ims...)
return len(ims)
}
showSeen := func() {
if len(seen) > 0 {
out = append(out, map[string]any{"role": "user", "content": seen})
seen = nil
}
}
for _, m := range ms {
var blocks []any
var calls []map[string]any
var text string
flush := func() {
if len(blocks) == 0 {
return
}
if m.Role == "user" && len(seen) > 0 {
blocks, seen = append(seen, blocks...), nil
}
out = append(out, map[string]any{"role": m.Role, "content": blocks})
blocks = nil
}
if m.Role != "user" {
showSeen()
}
for _, p := range m.Parts {
switch p.Kind {
case Text:
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
if text == "" {
text = p.Text
}
case Image:
if p.Data != "" {
blocks = append(blocks, map[string]any{"type": "image_url",
"image_url": map[string]any{"url": "data:" + p.MediaType + ";base64," + p.Data}})
}
case ToolCall:
if m.Role != "assistant" {
continue
}
id := p.ID
if id == "" {
id = "call_" + qoder.NewID()
}
names[id] = p.Name
calls = append(calls, map[string]any{"id": id, "type": "function",
"function": map[string]any{"name": p.Name, "arguments": string(argsOf(p))}})
case ToolResult:
flush()
txt := p.Text
if p.IsError {
txt = "Error: " + txt
}
if n := seeLater(p); n > 0 {
note := fmt.Sprintf("[The tool returned %d images; they follow in the next message.]", n)
if n == 1 {
note = "[The tool returned an image; it follows in the next message.]"
}
if strings.TrimSpace(txt) != "" {
txt += "\n\n"
}
txt += note
}
out = append(out, map[string]any{"role": "tool", "tool_call_id": p.CallID, "content": txt})
}
}
if m.Role == "assistant" && len(calls) > 0 {
out = append(out, map[string]any{"role": "assistant", "content": text, "tool_calls": calls})
continue
}
flush()
}
showSeen()
return out
}
// qoderTools is the caller's tools as Qoder's own native function tools.
func qoderTools(ts []Tool) []any {
var out []any
for _, t := range ts {
fn := map[string]any{"name": t.Name, "description": t.Description}
sch := t.Schema
if strings.TrimSpace(string(sch)) == "" {
sch = json.RawMessage(`{"type":"object","properties":{}}`)
}
fn["parameters"] = json.RawMessage(sch)
out = append(out, map[string]any{"type": "function", "function": fn})
}
return out
}
// qoderEffort is whether to think and at which of the model's own efforts
// ("" to send none). Asked for none, a model that always thinks thinks at
// its lowest; asked for nothing, or for a level Qoder doesn't name, it
// thinks at its own default.
func qoderEffort(asked string, model qoder.ModelInfo) (bool, string) {
want := strings.ToLower(asked)
if !slices.Contains(effortRank, want) {
want = ""
}
if !model.Thinks || want == "none" && !model.AlwaysThinks {
return false, ""
}
if len(model.Efforts) == 0 {
return true, ""
}
switch want {
case "":
return true, model.DefaultEffort
case "none":
return true, model.Efforts[0]
}
return true, fitEffort(want, model.Efforts)
}
// qoderSysText is the system prompt: Qoder's own line plus the caller's.
// The caller's tools go to Qoder as native function tools.
func qoderSysText(req *Request) string {
sys := qoderSys
if req.System != "" {
sys = sys + "\n\n" + req.System
}
if req.ToolChoice == "required" && len(req.Tools) > 0 {
sys += "\nYou must call an available function in this response."
}
return sys
}
// decodeQoder turns Qoder's SSE reply into events: each "data:" line carries an
// envelope whose "body" is a standard OpenAI chunk, whose content may hold
// embedded tool calls to lift out.
func decodeQoder(ctx context.Context, res *http.Response, out chan<- Event, model string) {
defer func() { res.Body.Close(); close(out) }()
send := func(ev Event) bool {
select {
case out <- ev:
return true
case <-ctx.Done():
return false
}
}
if !send(Event{Kind: KStart, MsgID: "msg_" + randomToken(), Model: model}) {
return
}
a := qoderText{tool: -1}
finish := func() {
for _, frag := range a.flush() {
if frag.text != "" && !send(Event{Kind: KText, Text: frag.text}) {
return
}
}
stop := "stop"
if a.sawTool {
stop = "tool"
}
send(Event{Kind: KStop, Stop: stop})
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64<<10), 32<<20)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || !gjson.Valid(payload) {
continue
}
inner := gjson.Get(payload, "body").String()
if status := gjson.Get(payload, "statusCodeValue"); status.Exists() && status.Int() != 200 {
data := []byte(inner)
if len(data) == 0 {
data = []byte(payload)
}
code, msg := qoderFailure(int(status.Int()), data)
send(Event{Kind: KError, Status: code, Text: msg})
return
}
if inner == "" || !json.Valid([]byte(inner)) {
continue
}
delta := gjson.Get(inner, "choices.0.delta")
if rc := delta.Get("reasoning_content").String(); rc != "" {
if !send(Event{Kind: KThink, Text: rc}) {
return
}
}
if content := delta.Get("content"); content.Exists() {
for _, frag := range a.feed(content.String()) {
ev := Event{}
if frag.call != nil {
ev = Event{Kind: KToolStart, ID: "call_" + qoder.NewID(), Name: frag.call.name}
if !send(ev) {
return
}
ev = Event{Kind: KToolArgs, Text: frag.call.args}
} else {
ev = Event{Kind: KText, Text: frag.text}
}
if !send(ev) {
return
}
}
}
// Qoder also serves tool calls in the OpenAI way: the id it sends
// is the one a result is answered with, so it is kept.
for _, tc := range delta.Get("tool_calls").Array() {
idx := int(tc.Get("index").Int())
if idx != a.tool {
a.tool = idx
for _, frag := range a.flush() {
if frag.text != "" && !send(Event{Kind: KText, Text: frag.text}) {
return
}
}
id := tc.Get("id").String()
if id == "" {
id = "call_" + qoder.NewID()
}
if !send(Event{Kind: KToolStart, ID: id, Name: tc.Get("function.name").String()}) {
return
}
a.sawTool = true
}
if args := tc.Get("function.arguments").String(); args != "" {
if !send(Event{Kind: KToolArgs, Text: args}) {
return
}
}
}
if fr := gjson.Get(inner, "choices.0.finish_reason").String(); fr != "" {
if u := gjson.Get(inner, "usage"); u.Exists() {
send(Event{Kind: KUsage, Usage: Usage{
Input: int(u.Get("prompt_tokens").Int()), Output: int(u.Get("completion_tokens").Int())}})
}
finish()
return
}
}
if err := sc.Err(); err != nil {
send(Event{Kind: KError, Status: 502, Text: "Qoder: reading stream: " + err.Error()})
return
}
finish()
}
// qoderText is the streaming tool-call splitter: it holds text until a full
// tool-call block arrives, then emits it as a call.
type qoderText struct {
textBuf string
callBuf string
inCall bool
sawTool bool
tool int // index of the native tool call being assembled, -1 for none
}
type qoderFrag struct {
text string
call *qoderCall
}
type qoderCall struct{ name, args string }
func (a *qoderText) feed(input string) []qoderFrag {
var out []qoderFrag
for input != "" {
if !a.inCall {
comb := a.textBuf + input
a.textBuf = ""
i := strings.Index(comb, qoderCallOpen)
if i < 0 {
if keep := qoderSuffix(comb); keep < len(comb) {
out = append(out, qoderFrag{text: comb[:len(comb)-keep]})
a.textBuf = comb[len(comb)-keep:]
} else {
a.textBuf = comb
}
break
}
if i > 0 {
out = append(out, qoderFrag{text: comb[:i]})
}
input = comb[i+len(qoderCallOpen):]
a.inCall = true
continue
}
comb := a.callBuf + input
a.callBuf = ""
j := strings.Index(comb, qoderCallClose)
if j < 0 {
a.callBuf = comb
break
}
if c, ok := qoderParseCall(comb[:j]); ok {
a.sawTool = true
out = append(out, qoderFrag{call: &c})
} else {
out = append(out, qoderFrag{text: qoderCallOpen + comb[:j] + qoderCallClose})
}
input = comb[j+len(qoderCallClose):]
a.inCall = false
}
return out
}
func (a *qoderText) flush() []qoderFrag {
if a.inCall {
t := qoderCallOpen + a.callBuf
a.inCall, a.callBuf = false, ""
if t != "" {
return []qoderFrag{{text: t}}
}
}
if a.textBuf != "" {
t := a.textBuf
a.textBuf = ""
return []qoderFrag{{text: t}}
}
return nil
}
// qoderSuffix is the length of the tail that could still become a call marker.
func qoderSuffix(v string) int {
for size := len(qoderCallOpen) - 1; size > 0; size-- {
if strings.HasSuffix(v, qoderCallOpen[:size]) {
return size
}
}
return 0
}
// qoderParseCall reads one tool-call block: a JSON {name,arguments}, or the
// XML function/parameter form.
func qoderParseCall(v string) (qoderCall, bool) {
var framed struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if json.Unmarshal([]byte(strings.TrimSpace(v)), &framed) == nil && strings.TrimSpace(framed.Name) != "" {
var m map[string]json.RawMessage
if json.Unmarshal(framed.Arguments, &m) == nil && m != nil {
return qoderCall{name: strings.TrimSpace(framed.Name), args: string(framed.Arguments)}, true
}
}
m := qoderFunc.FindStringSubmatch(v)
if len(m) != 3 {
return qoderCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return qoderCall{}, false
}
args := map[string]any{}
for _, pm := range qoderArg.FindAllStringSubmatch(m[2], -1) {
key := strings.TrimSpace(pm[1])
if key == "" {
continue
}
// A value is taken as it stands: nothing is told to escape them.
val := strings.TrimSpace(pm[2])
var dec any
if json.Unmarshal([]byte(val), &dec) != nil {
dec = val
}
args[key] = dec
}
b, err := json.Marshal(args)
if err != nil {
return qoderCall{}, false
}
return qoderCall{name: name, args: string(b)}, true
}