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:
1 parent
e9262f7ea8
commit
9debc58397
3 files changed
+156
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user