Files
magpie/internal/gateway/qoder_test.go
T

532 lines
21 KiB
Go

package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/qoder"
)
func testQoderModel(t *testing.T) qoder.ModelInfo {
t.Helper()
ms, err := qoder.ModelConfigs([]byte(`{"chat":[{"key":"qfmodel","enable":true,"display_name":"Qwen3.8-Flash","format":"openai","source":"system","is_vl":true,"is_reasoning":true,"max_input_tokens":200000,
"thinking_config":{"disabled":{"description":"Disable thinking"},"enabled":{"is_default":true,"efforts":{"xhigh":{},"medium":{"is_default":true},"low":{}}}}}]}`))
if err != nil || len(ms) != 1 {
t.Fatalf("model fixture: %v", err)
}
return ms[0]
}
// qoderUpstream stands in for api3.qoder.sh: it checks the wire body decodes to
// a well-formed Qoder chat body carrying the COSY envelope, then streams a
// Qoder SSE reply (each line an OpenAI chunk inside the envelope). It returns a
// cleanup that restores the real auth/URL hooks.
func qoderUpstream(t *testing.T, lines []string, modelKeys ...string) func() {
t.Helper()
modelKey := "qfmodel"
if len(modelKeys) > 0 {
modelKey = modelKeys[0]
}
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer COSY.") {
t.Errorf("missing COSY Authorization")
}
if r.Header.Get("X-Model-Key") != modelKey {
t.Errorf("X-Model-Key = %q", r.Header.Get("X-Model-Key"))
}
plain := qoder.DecodeRequestBody(string(raw))
if !gjson.ValidBytes(plain) {
t.Errorf("decoded body is not JSON: %q", plain)
}
if gjson.GetBytes(plain, "model_config.key").String() != modelKey {
t.Errorf("wrong body model configuration")
}
if gjson.GetBytes(plain, "agent_id").String() != "agent_common" {
t.Errorf("agent_id = %q", gjson.GetBytes(plain, "agent_id").String())
}
if !gjson.GetBytes(plain, "messages").IsArray() {
t.Errorf("no messages array in decoded body")
}
w.Header().Set("Content-Type", "text/event-stream")
for _, l := range lines {
io.WriteString(w, "data: "+l+"\n")
}
}))
auth, url, model := qoderAuth, qoderURL, qoderModel
qoderAuth = func(ctx context.Context, agent, user string) (*qoder.Credential, error) {
return &qoder.Credential{UID: "uid-test", Token: "jt-test", MachineID: "machine-test"}, nil
}
qoderModel = func(context.Context, string, string, string) (qoder.ModelInfo, error) { return testQoderModel(t), nil }
qoderURL = func(*qoder.Credential) string { return up.URL }
return func() { up.Close(); qoderAuth, qoderURL, qoderModel = auth, url, model }
}
func TestQoderSelectedModel(t *testing.T) {
done := qoderUpstream(t, []string{qoderChunk(qoderContent(t, "ok"))}, "other-model")
defer done()
ms, err := qoder.ModelConfigs([]byte(`{"chat":[{"key":"other-model","enable":true,"display_name":"Other","format":"custom","source":"system","is_vl":false,"is_reasoning":false,"max_input_tokens":64000,"custom_field":17}]}`))
if err != nil {
t.Fatal(err)
}
qoderModel = func(_ context.Context, _, user, key string) (qoder.ModelInfo, error) {
if user != "selected@x" || key != "other-model" {
t.Errorf("selected user/model %s %s", user, key)
}
return ms[0], nil
}
s := New()
ch, status, msg := s.askQoder("qoder", "other-model", "selected@x")(context.Background(), &Request{Model: "other-model", Effort: "max"})
if ch == nil {
t.Fatalf("ask: %d %s", status, msg)
}
for range ch {
}
b, err := qoderChatBody(&Request{Effort: "high"}, ms[0])
if err != nil {
t.Fatal(err)
}
if gjson.GetBytes(b, "model_config.custom_field").Int() != 17 || gjson.GetBytes(b, "parameters.context_length").Int() != 64000 || gjson.GetBytes(b, "parameters.enable_thinking").Bool() || gjson.GetBytes(b, "parameters.reasoning_effort").Exists() {
t.Fatalf("selected config: %s", b)
}
qoderModel = func(context.Context, string, string, string) (qoder.ModelInfo, error) {
return qoder.ModelInfo{}, errors.New("unknown model")
}
ch, status, _ = s.askQoder("qoder", "unknown", "selected@x")(context.Background(), &Request{})
if ch != nil || status != 400 {
t.Fatalf("unknown model: %d", status)
}
}
// Qoder's efforts come from thinking_config; asked for nothing, or for one
// the model doesn't offer, it thinks at its own default.
func TestQoderReasoning(t *testing.T) {
model := testQoderModel(t)
if model.DefaultEffort != "medium" || !model.Thinks || model.AlwaysThinks {
t.Fatalf("thinking_config read: %+v", model)
}
for _, tt := range []struct {
want, got string
thinking bool
}{
{"", "medium", true}, {"xhigh", "xhigh", true}, {"max", "xhigh", true}, {"none", "", false}, {"invalid", "medium", true},
} {
b, err := qoderChatBody(&Request{Effort: tt.want}, model)
if err != nil {
t.Fatal(err)
}
if gjson.GetBytes(b, "parameters.reasoning_effort").String() != tt.got || gjson.GetBytes(b, "parameters.enable_thinking").Bool() != tt.thinking {
t.Fatalf("effort %q: %s", tt.want, b)
}
}
// a model that can't stop thinking thinks at its lowest when asked for none
always, err := qoder.ModelConfigs([]byte(`{"chat":[{"key":"gf","enable":true,"is_reasoning":true,"thinking_config":{"enabled":{"is_default":true,"efforts":{"max":{"is_default":true},"high":{},"low":{}}}}}]}`))
if err != nil || !always[0].AlwaysThinks {
t.Fatalf("always-thinking model: %+v %v", always, err)
}
b, _ := qoderChatBody(&Request{Effort: "none"}, always[0])
if !gjson.GetBytes(b, "parameters.enable_thinking").Bool() || gjson.GetBytes(b, "parameters.reasoning_effort").String() != "low" {
t.Fatalf("always-thinking asked for none: %s", b)
}
}
// A tool call and its result are sent as OpenAI tool turns, so the result is
// paired with the call by id, not by order.
func TestQoderToolTurns(t *testing.T) {
msgs := qoderMessages([]Message{
{Role: "user", Parts: []Part{{Kind: Text, Text: "read it"}}},
{Role: "assistant", Parts: []Part{
{Kind: ToolCall, ID: "call_A", Name: "read", Args: json.RawMessage(`{"path":"/tmp/x & y"}`)},
{Kind: ToolCall, ID: "call_B", Name: "bash", Args: json.RawMessage(`{"command":"ls"}`)}}},
{Role: "user", Parts: []Part{
{Kind: ToolResult, CallID: "call_B", Text: "second result"},
{Kind: ToolResult, CallID: "call_A", Text: "first result", IsError: true}}},
})
b, _ := json.Marshal(msgs)
if len(msgs) != 4 {
t.Fatalf("turns %s", b)
}
got := gjson.ParseBytes(b)
if got.Get("1.tool_calls.0.id").String() != "call_A" || got.Get("1.tool_calls.1.id").String() != "call_B" {
t.Fatalf("tool_calls %s", got.Get("1").Raw)
}
// arguments reach Qoder as they stand, not HTML-escaped
if got.Get("1.tool_calls.0.function.arguments").String() != `{"path":"/tmp/x & y"}` {
t.Fatalf("arguments %s", got.Get("1.tool_calls.0").Raw)
}
if got.Get("2.role").String() != "tool" || got.Get("2.tool_call_id").String() != "call_B" ||
got.Get("2.content").String() != "second result" {
t.Fatalf("tool turn %s", got.Get("2").Raw)
}
}
// The caller's tools go to Qoder as native function tools.
func TestQoderNativeTools(t *testing.T) {
b, err := qoderChatBody(&Request{Tools: []Tool{{Name: "read", Description: "Read a file",
Schema: json.RawMessage(`{"type":"object"}`)}}}, testQoderModel(t))
if err != nil {
t.Fatal(err)
}
if gjson.GetBytes(b, "tools.0.type").String() != "function" || gjson.GetBytes(b, "tools.0.function.name").String() != "read" {
t.Fatalf("tools %s", b)
}
none, _ := qoderChatBody(&Request{ToolChoice: "none", Tools: []Tool{{Name: "read"}}}, testQoderModel(t))
if gjson.GetBytes(none, "tools").Exists() {
t.Fatalf("tools sent for tool_choice none: %s", none)
}
}
// Qoder's own tool_calls come back with the id they are answered with.
func TestQoderNativeToolCall(t *testing.T) {
calls := []string{
`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_up1","type":"function","function":{"name":"read","arguments":""}}]}}]}`,
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\": \"/tmp/x"}}]}}]}`,
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \\u0026 y\"}"}}]}}]}`,
`{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_up2","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]}}]}`,
`{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
}
var env []string
for _, c := range calls {
env = append(env, qoderChunk(c))
}
events := qoderDecoded(env...)
var ids []string
var args, stop string
for _, ev := range events {
switch ev.Kind {
case KToolStart:
ids = append(ids, ev.ID)
case KToolArgs:
args += ev.Text
case KStop:
stop = ev.Stop
}
}
if len(ids) != 2 || ids[0] != "call_up1" || ids[1] != "call_up2" {
t.Fatalf("call ids %v", ids)
}
// arguments are handed on as they arrive: nothing is unescaped twice
if args != `{"path": "/tmp/x \u0026 y"}{"command":"ls"}` {
t.Fatalf("arguments %q", args)
}
if stop != "tool" {
t.Fatalf("stop %q", stop)
}
}
func qoderDecoded(lines ...string) []Event {
var wire strings.Builder
for _, line := range lines {
fmt.Fprintf(&wire, "data: %s\n\n", line)
}
ch := make(chan Event, 32)
go decodeQoder(context.Background(), &http.Response{Body: io.NopCloser(strings.NewReader(wire.String()))}, ch, "qfmodel")
var events []Event
for ev := range ch {
events = append(events, ev)
}
return events
}
func TestQoderEOF(t *testing.T) {
call := qoderCallOpen + `{"name":"read","arguments":{"path":"x"}}` + qoderCallClose
for _, tt := range []struct{ input, text, stop string }{
{"a < b and x <", "a < b and x <", "stop"},
{call + "tail <", "tail <", "tool"},
{"before " + qoderCallOpen + "incomplete", "before " + qoderCallOpen + "incomplete", "stop"},
{"", "", "stop"},
} {
events := qoderDecoded(qoderChunk(qoderContent(t, tt.input)), "[DONE]")
var text, stop string
stops := 0
for _, ev := range events {
if ev.Kind == KText {
text += ev.Text
}
if ev.Kind == KStop {
stop = ev.Stop
stops++
}
}
if text != tt.text || stop != tt.stop || stops != 1 {
t.Fatalf("EOF %q => %q %q stops %d", tt.input, text, stop, stops)
}
}
}
func TestQoderEnvelopeErrors(t *testing.T) {
for _, status := range []int{401, 403, 429, 500} {
for _, proto := range []provider.Protocol{provider.Chat, provider.Responses, provider.Anthropic, provider.Gemini} {
for _, stream := range []bool{false, true} {
for _, partial := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%d/stream=%t/partial=%t", proto, status, stream, partial), func(t *testing.T) {
var lines []string
if partial {
lines = append(lines, qoderChunk(qoderContent(t, "some text")))
}
b, _ := json.Marshal(map[string]any{"statusCodeValue": status, "body": `{"message":"upstream failure"}`})
if status == 429 {
b, _ = json.Marshal(map[string]any{"statusCodeValue": status, "body": `{"message":"额度已耗尽"}`})
}
lines = append(lines, string(b))
done := qoderUpstream(t, lines)
defer done()
s := New()
req := &Request{Model: "qfmodel", Stream: stream}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, _, _ := s.askQoder("qoder", "qfmodel", "test")(ctx, req)
w := httptest.NewRecorder()
var usage Usage
code, msg := relayQoder(w, proto, req, events, &usage, cancel)
want := status
if want == 403 {
want = 401
}
if stream && partial {
want = 200
}
if code != want || w.Code != want || msg == "" || !strings.Contains(w.Body.String(), "error") {
t.Fatalf("code %d/%d want %d, msg %s body %s", code, w.Code, want, msg, w.Body.String())
}
})
}
}
}
}
}
func TestQoderSearchEnvelopeError(t *testing.T) {
for _, stream := range []bool{false, true} {
for _, partial := range []bool{false, true} {
ctx, cancel := context.WithCancel(context.Background())
in := make(chan Event, 3)
in <- Event{Kind: KStart}
if partial {
in <- Event{Kind: KText, Text: "partial answer"}
}
in <- Event{Kind: KError, Status: 503, Text: "Qoder upstream unavailable"}
close(in)
out := make(chan Event, 4)
req := &Request{Model: "qfmodel", Stream: stream}
go func() {
defer close(out)
New().searchRounds(ctx, req, "search", in, nil, out)
}()
w := httptest.NewRecorder()
var usage Usage
code, msg := relayQoder(w, provider.Chat, req, out, &usage, cancel)
cancel()
want := 503
if stream && partial {
want = 200
}
if code != want || w.Code != want || msg == "" || !strings.Contains(w.Body.String(), "error") {
t.Fatalf("stream=%t partial=%t: code %d/%d want %d, msg %s body %s", stream, partial, code, w.Code, want, msg, w.Body.String())
}
}
}
}
// qoderChunk wraps an OpenAI chat.completion.chunk as Qoder's SSE envelope.
func qoderChunk(inner string) string {
b, _ := json.Marshal(map[string]any{
"headers": map[string][]string{"Content-Type": {"application/json"}},
"body": inner, "statusCodeValue": 200, "statusCode": "OK",
})
return string(b)
}
// TestQoderChatBody checks the IR request becomes a Qoder body: the caller's
// system prompt and tool contract ride in the system field, tool calls are
// replayed as XML, and tool results fold into a user turn.
func TestQoderChatBody(t *testing.T) {
req := &Request{
System: "Be terse.",
ToolChoice: "",
Tools: []Tool{{Name: "read", Description: "Read a file", Schema: json.RawMessage(`{"type":"object"}`)}},
Messages: []Message{
{Role: "user", Parts: []Part{{Kind: Text, Text: "hi"}}},
{Role: "assistant", Parts: []Part{{Kind: ToolCall, ID: "c1", Name: "read", Args: json.RawMessage(`{"path":"x"}`)}}},
{Role: "user", Parts: []Part{{Kind: ToolResult, CallID: "c1", Text: "the file"}}},
},
}
b, err := qoderChatBody(req, testQoderModel(t))
if err != nil {
t.Fatal(err)
}
if gjson.GetBytes(b, "agent_id").String() != "agent_common" {
t.Fatalf("agent_id")
}
if gjson.GetBytes(b, "model_config.key").String() != "qfmodel" {
t.Fatalf("model key")
}
// the caller's tools go as Qoder's own function tools, not in the prompt
if gjson.GetBytes(b, "tools.0.function.name").String() != "read" ||
!strings.Contains(gjson.GetBytes(b, "system.0.text").String(), "Be terse.") {
t.Fatalf("system/tools: %s", b)
}
msgs := gjson.GetBytes(b, "messages")
if msgs.Get("0.role").String() != "system" {
t.Fatalf("first message is %s, want system", msgs.Get("0.role").String())
}
// assistant tool call and tool result are tool turns named by id
if msgs.Get("2.tool_calls.0.id").String() != "c1" ||
msgs.Get("3.role").String() != "tool" || msgs.Get("3.tool_call_id").String() != "c1" ||
msgs.Get("3.content").String() != "the file" {
t.Fatalf("tool turns: %s", msgs.Raw)
}
}
// qoderContent builds an OpenAI chunk inner JSON whose delta carries content,
// assembled through encoding/json so a Qoder tool-call block, written here with
// hex-escaped brackets, reaches the wire as real angle brackets without any
// literal tag in this source.
func qoderContent(t *testing.T, content string) string {
t.Helper()
b, err := json.Marshal(map[string]any{
"choices": []any{map[string]any{"delta": map[string]any{"content": content}}},
"object": "chat.completion.chunk",
})
if err != nil {
t.Fatal(err)
}
return string(b)
}
// TestServeQoder runs a request through serveQoder against a stubbed Qoder:
// text, a reasoning delta, an embedded tool call lifted into events, and usage
// all reach the client's protocol.
func TestServeQoder(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
call := "\x3ctool_call\x3e\n\x3cfunction=read\x3e\n\x3cparameter=path\x3ex\x3c/parameter\x3e\n\x3c/function\x3e\n\x3c/tool_call\x3e"
done := qoderUpstream(t, []string{
qoderChunk(`{"choices":[{"delta":{"role":"assistant","reasoning_content":"thinking"}}],"object":"chat.completion.chunk"}`),
qoderChunk(`{"choices":[{"delta":{"content":"Hi there"}}],"object":"chat.completion.chunk"}`),
qoderChunk(qoderContent(t, call)),
qoderChunk(`{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`),
`{"firstTokenDuration":50,"totalDuration":200}`,
})
defer done()
s := New()
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"model":"qoder/qfmodel","stream":true,"messages":[{"role":"user","content":"hi"}]}`))
var u Usage
if status, msg := s.serveQoder(w, r, provider.Chat, provider.Provider{Account: &provider.Account{User: "test"}}, "qfmodel", []byte(`{"model":"qfmodel","stream":true,"messages":[{"role":"user","content":"hi"}]}`), &u); status != 200 {
t.Fatalf("status %d msg %q", status, msg)
}
out := w.Body.String()
for _, kw := range []string{"thinking", "Hi there", "read", "path", "x"} {
if !strings.Contains(out, kw) {
t.Fatalf("streamed output missing %q:\n%s", kw, out)
}
}
}
// Thinking turned off reaches Qoder as off: reasoning_effort "none", which
// Effort reads as low for other vendors, and Anthropic's thinking disabled.
func TestQoderThinkingOff(t *testing.T) {
model := testQoderModel(t)
always, err := qoder.ModelConfigs([]byte(`{"chat":[{"key":"gf","enable":true,"is_reasoning":true,"thinking_config":{"enabled":{"is_default":true,"efforts":{"max":{"is_default":true},"high":{},"low":{}}}}}]}`))
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
from provider.Protocol
body string
}{
{provider.Chat, `{"model":"m","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`},
{provider.Responses, `{"model":"m","reasoning":{"effort":"none"},"input":"hi"}`},
{provider.Anthropic, `{"model":"m","max_tokens":100,"thinking":{"type":"disabled"},"messages":[{"role":"user","content":"hi"}]}`},
} {
req, err := parse(tt.from, []byte(tt.body))
if err != nil {
t.Fatal(err)
}
if tt.from != provider.Anthropic && req.Effort != "low" {
t.Errorf("%s: other vendors' effort changed: %q", tt.from, req.Effort)
}
b, _ := qoderChatBody(req, model)
if gjson.GetBytes(b, "parameters.enable_thinking").Bool() || gjson.GetBytes(b, "parameters.reasoning_effort").Exists() {
t.Errorf("%s: thinking off on a model that can stop: %s", tt.from, gjson.GetBytes(b, "parameters").Raw)
}
b, _ = qoderChatBody(req, always[0])
if !gjson.GetBytes(b, "parameters.enable_thinking").Bool() || gjson.GetBytes(b, "parameters.reasoning_effort").String() != "low" {
t.Errorf("%s: thinking off on an always-thinking model: %s", tt.from, gjson.GetBytes(b, "parameters").Raw)
}
}
// thinking not mentioned is the model's own default, as before
req, _ := parse(provider.Anthropic, []byte(`{"model":"m","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}`))
b, _ := qoderChatBody(req, always[0])
if gjson.GetBytes(b, "parameters.reasoning_effort").String() != "max" {
t.Errorf("unmentioned thinking: %s", gjson.GetBytes(b, "parameters").Raw)
}
}
// Only a sign-in that is gone is a 401 (the agent asks to log in again); a
// refresh that timed out or failed on the network is a 502.
func TestQoderCredentialStatus(t *testing.T) {
done := qoderUpstream(t, nil)
defer done()
s := New()
for _, tt := range []struct {
err error
want int
}{
{fmt.Errorf("Qoder: refresh: %w", context.DeadlineExceeded), 502},
{errors.New("dial tcp: connection refused"), 502},
{fmt.Errorf("one@x's Qoder sign-in has expired — sign in again (%w)", provider.ErrQoderSignIn), 401},
} {
qoderAuth = func(context.Context, string, string) (*qoder.Credential, error) { return nil, tt.err }
ch, status, _ := s.askQoder("qoder", "qfmodel", "one@x")(context.Background(), &Request{Model: "qfmodel"})
if ch != nil || status != tt.want {
t.Errorf("%v: status %d, want %d", tt.err, status, tt.want)
}
}
}
// A tool turn holds text only, so a tool's images follow in a user turn,
// joined to the user's own next turn.
func TestQoderToolResultImages(t *testing.T) {
png := Part{Kind: Image, MediaType: "image/png", Data: "iVBOR"}
msgs := qoderMessages([]Message{
{Role: "user", Parts: []Part{{Kind: Text, Text: "look"}}},
{Role: "assistant", Parts: []Part{{Kind: ToolCall, ID: "call_A", Name: "screenshot", Args: json.RawMessage(`{}`)}}},
{Role: "user", Parts: []Part{
{Kind: ToolResult, CallID: "call_A", Text: "shot taken", Images: []Part{png}},
{Kind: Text, Text: "what do you see?"}}},
{Role: "assistant", Parts: []Part{{Kind: ToolCall, ID: "call_B", Name: "screenshot", Args: json.RawMessage(`{}`)}}},
{Role: "user", Parts: []Part{{Kind: ToolResult, CallID: "call_B", Images: []Part{png, png}}}},
})
b, _ := json.Marshal(msgs)
got := gjson.ParseBytes(b)
if len(msgs) != 7 {
t.Fatalf("turns %s", b)
}
if got.Get("2.role").String() != "tool" || !strings.Contains(got.Get("2.content").String(), "shot taken") ||
!strings.Contains(got.Get("2.content").String(), "an image; it follows") {
t.Fatalf("tool turn %s", got.Get("2").Raw)
}
u := got.Get("3")
if u.Get("role").String() != "user" || !strings.Contains(u.Get("content.0.text").String(), "screenshot (tool call call_A)") ||
u.Get("content.1.image_url.url").String() != "data:image/png;base64,iVBOR" || u.Get("content.2.text").String() != "what do you see?" {
t.Fatalf("images with the user's turn %s", u.Raw)
}
if got.Get("5.role").String() != "tool" || !strings.Contains(got.Get("5.content").String(), "2 images") {
t.Fatalf("second tool turn %s", got.Get("5").Raw)
}
if got.Get("6.role").String() != "user" || got.Get("6.content.#").Int() != 3 {
t.Fatalf("trailing images %s", got.Get("6").Raw)
}
}