gateway: a Cline reply that is not streamed reaches the agent as the completion itself, not in Cline's {success, data} envelope (6094S on Discord) — Cline's API answers a request with "stream": false as {"success":true,"data":{chat.completion}}, and magpie passed that on byte for byte to an agent asking the Chat API of ClinePass (cline-pass/deepseek-v4.1-flash): a client looking for choices at the top failed, Alma's tool-model calls with "Invalid JSON response". Streams were fine, and a translated request is asked of Cline as a stream, so only the Chat API's whole replies were hit. forwardOnce now takes the envelope off a Cline provider's (IsCline: the ClinePass preset or a cline.bot host) JSON reply (clineUnwrapped): success true with an object data goes on as data, so chatWhole, the usage sniffer and the agent see the completion; success false goes on as {"error":{"message":…}}, a 502 when it came with a 2xx, which writeError puts in the agent's API's shape. A stream, a compressed reply, a body without success and another provider's replies pass as they came. The community plugin @magpie-community/opencode-cline-auth already unwraps it (unwrapped in index.mjs) and needs nothing. TestClineEnvelopeUnwrapped (the completion on the Chat API, another provider's body untouched, a refusal as an error, a stream untouched) fails without the change. No Cline account to test with; Cline's real API answers a request without a key with a plain {"error":…} 401, which is unchanged. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 21:01:28 +08:00
1 parent e9262f7ea8
commit 9debc58397
3 files changed
+156 -1

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
package gateway
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/provider"
)
// clineUnwrapped is a Cline reply with the {success, data} envelope its API
// puts a whole (non-streamed) answer in taken off: {"success":true,"data":
// {chat.completion}} goes on as the completion, which every client of the
// Chat API reads at the top (6094S on Discord: Alma's tool-model calls
// failed with "Invalid JSON response"), and the translators read it so too.
// {"success":false,"error":…} goes on as an error, {"error":{"message":…}},
// with a 502 when it came with a 2xx. A stream, a compressed reply and any
// other body pass as they came; a provider other than Cline is never asked.
func clineUnwrapped(p provider.Provider, res *http.Response) *http.Response {
if res == nil || !p.IsCline() || res.Header.Get("Content-Encoding") != "" {
return res
}
ct := res.Header.Get("Content-Type")
if !strings.Contains(ct, "json") || strings.Contains(ct, "event-stream") {
return res
}
b, err := io.ReadAll(io.LimitReader(res.Body, 64<<20))
res.Body.Close()
set := func(nb []byte) *http.Response {
res.Body = io.NopCloser(bytes.NewReader(nb))
res.ContentLength = int64(len(nb))
res.Header.Set("Content-Length", strconv.Itoa(len(nb)))
return res
}
if err != nil {
return set(b)
}
ok := gjson.GetBytes(b, "success")
if ok.Type != gjson.True && ok.Type != gjson.False {
return set(b)
}
if ok.Type == gjson.True {
if d := gjson.GetBytes(b, "data"); d.IsObject() {
return set([]byte(d.Raw))
}
return set(b)
}
msg := "Cline request failed"
if e := gjson.GetBytes(b, "error"); e.Type == gjson.String && e.Str != "" {
msg = e.Str
} else if m := e.Get("message"); m.Type == gjson.String && m.Str != "" {
msg = m.Str
} else if m := gjson.GetBytes(b, "message"); m.Type == gjson.String && m.Str != "" {
msg = m.Str
}
nb, _ := json.Marshal(map[string]any{"error": map[string]any{"message": msg}})
if res.StatusCode < 400 {
res.StatusCode, res.Status = http.StatusBadGateway, "502 Bad Gateway"
}
return set(nb)
}
+85
View File
@@ -0,0 +1,85 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// Cline's API answers a request that isn't streamed with the completion in
// {"success":true,"data":…} (6094S on Discord: Alma's tool-model calls read
// no choices and failed). An agent gets the completion itself;
// {"success":false} is an error in the agent's API's shape; a stream and
// another provider's reply pass as they came. (A translated request is
// asked of Cline as a stream, which comes plain.)
func TestClineEnvelopeUnwrapped(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
cline, err := provider.FromPreset("clinepass")
if err != nil {
t.Fatal(err)
}
cline.ID, cline.Key, cline.Models = "cline", "clp_key", []string{"deepseek/deepseek-v4.1-flash"}
other := provider.Provider{ID: "other", Name: "Other", Key: "k", Chat: "https://other.test/v1", Models: []string{"deepseek/deepseek-v4.1-flash"}}
for _, p := range []provider.Provider{cline, other} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
const completion = `{"id":"gen_1","object":"chat.completion","model":"deepseek/deepseek-v4.1-flash","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`
reply := `{"success":true,"data":` + completion + `}`
ctype := "application/json; charset=utf-8"
s := New()
s.client = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {ctype}},
Body: io.NopCloser(strings.NewReader(reply))}, nil
})}
ask := func(path, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
chat := func(id string) string {
return `{"model":"` + id + `/deepseek/deepseek-v4.1-flash","stream":false,"messages":[{"role":"user","content":"Say pong"}]}`
}
code, body := ask("/v1/chat/completions", chat("cline"))
var got struct {
Object string `json:"object"`
Choices []struct {
Message struct{ Content string } `json:"message"`
} `json:"choices"`
Success *bool `json:"success"`
}
if err := json.Unmarshal([]byte(body), &got); code != 200 || err != nil || got.Success != nil || got.Object != "chat.completion" ||
len(got.Choices) != 1 || got.Choices[0].Message.Content != "pong" {
t.Fatalf("chat: %d %s", code, body)
}
// another provider's body is its own
if code, body = ask("/v1/chat/completions", chat("other")); code != 200 || body != reply {
t.Fatalf("other: %d %s", code, body)
}
// a refusal in the envelope
reply = `{"success":false,"error":"Insufficient balance"}`
code, body = ask("/v1/chat/completions", chat("cline"))
if code != 502 || !strings.Contains(body, "Insufficient balance") || !strings.Contains(body, `"error"`) || strings.Contains(body, `"success"`) {
t.Fatalf("refused: %d %s", code, body)
}
// a stream passes as it came
reply = "data: {\"success\":true,\"data\":{}}\n\ndata: [DONE]\n\n"
ctype = "text/event-stream"
code, body = ask("/v1/chat/completions", `{"model":"cline/deepseek/deepseek-v4.1-flash","stream":true,"messages":[{"role":"user","content":"Say pong"}]}`)
if code != 200 || !strings.Contains(body, `"success":true`) {
t.Fatalf("stream: %d %s", code, body)
}
}
+6 -1
View File
@@ -2485,7 +2485,12 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
if err := p.Sign(ctx, req, to, body); err != nil {
return nil, err
}
return p.Do(s.client, req)
res, err := p.Do(s.client, req)
if err != nil {
return res, err
}
// Cline's whole replies come in {success, data}
return clineUnwrapped(p, res), nil
}
// fromClaudeCode is a request Claude Code sent, by the User-Agent it gives