From 9debc58397cd2af8a6d50bea87e353b8fd1e6e9c Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 20:56:37 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20a=20Cline=20reply=20that=20is=20not?= =?UTF-8?q?=20streamed=20reaches=20the=20agent=20as=20the=20completion=20i?= =?UTF-8?q?tself,=20not=20in=20Cline's=20{success,=20data}=20envelope=20(6?= =?UTF-8?q?094S=20on=20Discord)=20=E2=80=94=20Cline's=20API=20answers=20a?= =?UTF-8?q?=20request=20with=20"stream":=20false=20as=20{"success":true,"d?= =?UTF-8?q?ata":{chat.completion}},=20and=20magpie=20passed=20that=20on=20?= =?UTF-8?q?byte=20for=20byte=20to=20an=20agent=20asking=20the=20Chat=20API?= =?UTF-8?q?=20of=20ClinePass=20(cline-pass/deepseek-v4.1-flash):=20a=20cli?= =?UTF-8?q?ent=20looking=20for=20choices=20at=20the=20top=20failed,=20Alma?= =?UTF-8?q?'s=20tool-model=20calls=20with=20"Invalid=20JSON=20response".?= =?UTF-8?q?=20Streams=20were=20fine,=20and=20a=20translated=20request=20is?= =?UTF-8?q?=20asked=20of=20Cline=20as=20a=20stream,=20so=20only=20the=20Ch?= =?UTF-8?q?at=20API's=20whole=20replies=20were=20hit.=20forwardOnce=20now?= =?UTF-8?q?=20takes=20the=20envelope=20off=20a=20Cline=20provider's=20(IsC?= =?UTF-8?q?line:=20the=20ClinePass=20preset=20or=20a=20cline.bot=20host)?= =?UTF-8?q?=20JSON=20reply=20(clineUnwrapped):=20success=20true=20with=20a?= =?UTF-8?q?n=20object=20data=20goes=20on=20as=20data,=20so=20chatWhole,=20?= =?UTF-8?q?the=20usage=20sniffer=20and=20the=20agent=20see=20the=20complet?= =?UTF-8?q?ion;=20success=20false=20goes=20on=20as=20{"error":{"message":?= =?UTF-8?q?=E2=80=A6}},=20a=20502=20when=20it=20came=20with=20a=202xx,=20w?= =?UTF-8?q?hich=20writeError=20puts=20in=20the=20agent's=20API's=20shape.?= =?UTF-8?q?=20A=20stream,=20a=20compressed=20reply,=20a=20body=20without?= =?UTF-8?q?=20success=20and=20another=20provider's=20replies=20pass=20as?= =?UTF-8?q?=20they=20came.=20The=20community=20plugin=20@magpie-community/?= =?UTF-8?q?opencode-cline-auth=20already=20unwraps=20it=20(unwrapped=20in?= =?UTF-8?q?=20index.mjs)=20and=20needs=20nothing.=20TestClineEnvelopeUnwra?= =?UTF-8?q?pped=20(the=20completion=20on=20the=20Chat=20API,=20another=20p?= =?UTF-8?q?rovider's=20body=20untouched,=20a=20refusal=20as=20an=20error,?= =?UTF-8?q?=20a=20stream=20untouched)=20fails=20without=20the=20change.=20?= =?UTF-8?q?No=20Cline=20account=20to=20test=20with;=20Cline's=20real=20API?= =?UTF-8?q?=20answers=20a=20request=20without=20a=20key=20with=20a=20plain?= =?UTF-8?q?=20{"error":=E2=80=A6}=20401,=20which=20is=20unchanged.=20go=20?= =?UTF-8?q?vet,=20go=20build=20(darwin,=20GOOS=3Dlinux=20nogui,=20GOOS=3Dw?= =?UTF-8?q?indows)=20and=20go=20test=20-tags=20nogui=20./...=20under=20a?= =?UTF-8?q?=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/cline_envelope.go | 65 +++++++++++++++++++ internal/gateway/cline_envelope_test.go | 85 +++++++++++++++++++++++++ internal/gateway/gateway.go | 7 +- 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/cline_envelope.go create mode 100644 internal/gateway/cline_envelope_test.go diff --git a/internal/gateway/cline_envelope.go b/internal/gateway/cline_envelope.go new file mode 100644 index 00000000..346b2cbc --- /dev/null +++ b/internal/gateway/cline_envelope.go @@ -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) +} diff --git a/internal/gateway/cline_envelope_test.go b/internal/gateway/cline_envelope_test.go new file mode 100644 index 00000000..9a7e2410 --- /dev/null +++ b/internal/gateway/cline_envelope_test.go @@ -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) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 5007adc8..233c9bc9 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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