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