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