1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12)
13
14func seedCopilotTestToken(t *testing.T, baseURL string) string {
15 t.Helper()
16 pat := "synthetic-" + t.Name()
17 copilotCache.mu.Lock()
18 copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)}
19 copilotCache.mu.Unlock()
20 t.Cleanup(func() {
21 copilotCache.mu.Lock()
22 delete(copilotCache.tokens, pat)
23 copilotCache.mu.Unlock()
24 })
25 return pat
26}
27
28func TestCopilotWireProtocols(t *testing.T) {
29 for _, tc := range []struct{ model, path string }{
30 {"claude-sonnet-5", "/v1/messages"},
31 {"claude-haiku-4.5", "/v1/messages"},
32 {"gpt-5.4", "/responses"},
33 {"grok-4.6", "/responses"},
34 {"mai-code-1.1-flash", "/responses"},
35 {"gemini-3.8-flash", "/chat/completions"},
36 {"kimi-k3", "/chat/completions"},
37 } {
38 t.Run(tc.model, func(t *testing.T) {
39 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 if r.URL.Path != tc.path {
41 t.Errorf("path = %q, want %q", r.URL.Path, tc.path)
42 }
43 if r.Header.Get("Authorization") != "Bearer synthetic-session-token" {
44 t.Error("missing session Bearer auth")
45 }
46 if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" {
47 t.Error("missing Copilot identity headers")
48 }
49 for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} {
50 if r.Header.Get(header) != "" {
51 t.Errorf("unexpected identity header %s", header)
52 }
53 }
54 var body map[string]json.RawMessage
55 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
56 t.Error(err)
57 w.WriteHeader(400)
58 return
59 }
60 if string(body["model"]) != fmt.Sprintf("%q", tc.model) {
61 t.Error("model ID changed on wire")
62 }
63 w.Header().Set("Content-Type", "text/event-stream")
64 switch tc.path {
65 case "/v1/messages":
66 if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 {
67 t.Error("missing Messages fields")
68 }
69 if strings.Contains(string(body["system"]), "Claude Code") {
70 t.Error("injected Claude Code system identity")
71 }
72 if tc.model == "claude-sonnet-5" {
73 if string(body["thinking"]) != `{"type":"adaptive"}` {
74 t.Errorf("thinking = %s", body["thinking"])
75 }
76 if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 {
77 t.Error("invalid adaptive thinking payload")
78 }
79 }
80 fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
81 case "/responses":
82 if len(body["input"]) == 0 || len(body["reasoning"]) == 0 {
83 t.Error("missing Responses fields")
84 }
85 fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
86 default:
87 if len(body["reasoning_effort"]) != 0 {
88 t.Error("unsupported reasoning_effort sent")
89 }
90 if len(body["messages"]) == 0 {
91 t.Error("missing chat messages")
92 }
93 fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
94 }
95 }))
96 defer srv.Close()
97 client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL))
98 stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}})
99 if err != nil {
100 t.Fatal(err)
101 }
102 for event := range stream {
103 if e, ok := event.(EventDone); ok && e.Err != nil {
104 t.Fatalf("stream error: %v", e.Err)
105 }
106 if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" {
107 t.Errorf("event provider = %s", e.Provider)
108 }
109 }
110 })
111 }
112}
113
114type copilotMetadataCapture struct {
115 metadata copilotRequestMetadata
116 reasoning string
117}
118
119func (c *copilotMetadataCapture) Name() string { return "github-copilot" }
120func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) {
121 c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata)
122 c.reasoning = req.Reasoning
123 ch := make(chan Event)
124 close(ch)
125 return ch, nil
126}
127
128func TestCopilotDynamicRequestMetadata(t *testing.T) {
129 client := NewGithubCopilotClient("unused").(*copilotClient)
130 capture := &copilotMetadataCapture{}
131 client.router.byAPI[APICompletions] = capture
132 for _, tc := range []struct {
133 messages []Message
134 initiator string
135 vision bool
136 }{
137 {nil, "user", false},
138 {[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true},
139 {[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true},
140 {[]Message{{Role: RoleAssistant}}, "agent", false},
141 {[]Message{{Role: RoleUser}}, "user", false},
142 } {
143 _, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages})
144 if err != nil {
145 t.Fatal(err)
146 }
147 if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" {
148 t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning)
149 }
150 }
151 // The transport must propagate vision metadata and remove it on the next text-only request.
152 mock := &mockRoundTripper{}
153 transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)}
154 for _, vision := range []bool{true, false} {
155 ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision})
156 req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil)
157 req.Header.Set("Copilot-Vision-Request", "stale")
158 resp, err := transport.RoundTrip(req)
159 if err != nil {
160 t.Fatal(err)
161 }
162 resp.Body.Close()
163 want := ""
164 if vision {
165 want = "true"
166 }
167 if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" {
168 t.Error("incorrect dynamic transport headers")
169 }
170 if req.Header.Get("Copilot-Vision-Request") != "stale" {
171 t.Error("transport mutated caller headers")
172 }
173 }
174}