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) } }