History

feat(auth): add GitHub Copilot authentication and API integration

Add complete GitHub Copilot support including: - Device flow OAuth authentication via GitHub's official client ID - Token management with automatic caching (25 min TTL) - OpenAI-compatible API executor for api.githubcopilot.com - 16 model definitions (GPT-5 variants, Claude variants, Gemini, Grok, Raptor) - CLI login command via -github-copilot-login flag - SDK authenticator and refresh registry integration Enables users to authenticate with their GitHub Copilot subscription and use it as a backend provider alongside existing providers.

Ernesto Martínez committed 11 months ago

Commit 3a9ac7e1 parent d2e463912 files · +1563 −1Browse files

Showing the changes to internal/runtime/executor/github_copilot_executor.go (1 of 12 files). Show all

Unified · Split

internal/runtime/executor/github_copilot_executor.go+354−0
@@ -0,0 +1,354 @@
1package executor
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "io"
9 "net/http"
10 "time"
11
12 "github.com/google/uuid"
13 copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
14 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
16 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
17 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
18 log "github.com/sirupsen/logrus"
19 "github.com/tidwall/sjson"
20)
21
22const (
23 githubCopilotBaseURL = "https://api.githubcopilot.com"
24 githubCopilotChatPath = "/chat/completions"
25 githubCopilotAuthType = "github-copilot"
26 githubCopilotTokenCacheTTL = 25 * time.Minute
27)
28
29// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
30type GitHubCopilotExecutor struct {
31 cfg *config.Config
32}
33
34// NewGitHubCopilotExecutor constructs a new executor instance.
35func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
36 return &GitHubCopilotExecutor{cfg: cfg}
37}
38
39// Identifier implements ProviderExecutor.
40func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
41
42// PrepareRequest implements ProviderExecutor.
43func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
44 return nil
45}
46
47// Execute handles non-streaming requests to GitHub Copilot.
48func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
49 apiToken, errToken := e.ensureAPIToken(ctx, auth)
50 if errToken != nil {
51 return resp, errToken
52 }
53
54 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
55 defer reporter.trackFailure(ctx, &err)
56
57 from := opts.SourceFormat
58 to := sdktranslator.FromString("openai")
59 body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
60 body = e.normalizeModel(req.Model, body)
61 body = applyPayloadConfig(e.cfg, req.Model, body)
62 body, _ = sjson.SetBytes(body, "stream", false)
63
64 url := githubCopilotBaseURL + githubCopilotChatPath
65 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
66 if err != nil {
67 return resp, err
68 }
69 e.applyHeaders(httpReq, apiToken)
70
71 var authID, authLabel, authType, authValue string
72 if auth != nil {
73 authID = auth.ID
74 authLabel = auth.Label
75 authType, authValue = auth.AccountInfo()
76 }
77 recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
78 URL: url,
79 Method: http.MethodPost,
80 Headers: httpReq.Header.Clone(),
81 Body: body,
82 Provider: e.Identifier(),
83 AuthID: authID,
84 AuthLabel: authLabel,
85 AuthType: authType,
86 AuthValue: authValue,
87 })
88
89 httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
90 httpResp, err := httpClient.Do(httpReq)
91 if err != nil {
92 recordAPIResponseError(ctx, e.cfg, err)
93 return resp, err
94 }
95 defer func() {
96 if errClose := httpResp.Body.Close(); errClose != nil {
97 log.Errorf("github-copilot executor: close response body error: %v", errClose)
98 }
99 }()
100
101 recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
102
103 if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
104 data, _ := io.ReadAll(httpResp.Body)
105 appendAPIResponseChunk(ctx, e.cfg, data)
106 log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
107 err = statusErr{code: httpResp.StatusCode, msg: string(data)}
108 return resp, err
109 }
110
111 data, err := io.ReadAll(httpResp.Body)
112 if err != nil {
113 recordAPIResponseError(ctx, e.cfg, err)
114 return resp, err
115 }
116 appendAPIResponseChunk(ctx, e.cfg, data)
117
118 detail := parseOpenAIUsage(data)
119 if detail.TotalTokens > 0 {
120 reporter.publish(ctx, detail)
121 }
122
123 var param any
124 converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
125 resp = cliproxyexecutor.Response{Payload: []byte(converted)}
126 reporter.ensurePublished(ctx)
127 return resp, nil
128}
129
130// ExecuteStream handles streaming requests to GitHub Copilot.
131func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
132 apiToken, errToken := e.ensureAPIToken(ctx, auth)
133 if errToken != nil {
134 return nil, errToken
135 }
136
137 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
138 defer reporter.trackFailure(ctx, &err)
139
140 from := opts.SourceFormat
141 to := sdktranslator.FromString("openai")
142 body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
143 body = e.normalizeModel(req.Model, body)
144 body = applyPayloadConfig(e.cfg, req.Model, body)
145 body, _ = sjson.SetBytes(body, "stream", true)
146 // Enable stream options for usage stats in stream
147 body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
148
149 url := githubCopilotBaseURL + githubCopilotChatPath
150 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
151 if err != nil {
152 return nil, err
153 }
154 e.applyHeaders(httpReq, apiToken)
155
156 var authID, authLabel, authType, authValue string
157 if auth != nil {
158 authID = auth.ID
159 authLabel = auth.Label
160 authType, authValue = auth.AccountInfo()
161 }
162 recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
163 URL: url,
164 Method: http.MethodPost,
165 Headers: httpReq.Header.Clone(),
166 Body: body,
167 Provider: e.Identifier(),
168 AuthID: authID,
169 AuthLabel: authLabel,
170 AuthType: authType,
171 AuthValue: authValue,
172 })
173
174 httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
175 httpResp, err := httpClient.Do(httpReq)
176 if err != nil {
177 recordAPIResponseError(ctx, e.cfg, err)
178 return nil, err
179 }
180
181 recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
182
183 if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
184 data, readErr := io.ReadAll(httpResp.Body)
185 if errClose := httpResp.Body.Close(); errClose != nil {
186 log.Errorf("github-copilot executor: close response body error: %v", errClose)
187 }
188 if readErr != nil {
189 recordAPIResponseError(ctx, e.cfg, readErr)
190 return nil, readErr
191 }
192 appendAPIResponseChunk(ctx, e.cfg, data)
193 log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
194 err = statusErr{code: httpResp.StatusCode, msg: string(data)}
195 return nil, err
196 }
197
198 out := make(chan cliproxyexecutor.StreamChunk)
199 stream = out
200
201 go func() {
202 defer close(out)
203 defer func() {
204 if errClose := httpResp.Body.Close(); errClose != nil {
205 log.Errorf("github-copilot executor: close response body error: %v", errClose)
206 }
207 }()
208
209 scanner := bufio.NewScanner(httpResp.Body)
210 scanner.Buffer(nil, 20_971_520)
211 var param any
212
213 for scanner.Scan() {
214 line := scanner.Bytes()
215 appendAPIResponseChunk(ctx, e.cfg, line)
216
217 // Parse SSE data
218 if bytes.HasPrefix(line, dataTag) {
219 data := bytes.TrimSpace(line[5:])
220 if bytes.Equal(data, []byte("[DONE]")) {
221 continue
222 }
223 if detail, ok := parseOpenAIStreamUsage(line); ok {
224 reporter.publish(ctx, detail)
225 }
226 }
227
228 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
229 for i := range chunks {
230 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
231 }
232 }
233
234 if errScan := scanner.Err(); errScan != nil {
235 recordAPIResponseError(ctx, e.cfg, errScan)
236 reporter.publishFailure(ctx)
237 out <- cliproxyexecutor.StreamChunk{Err: errScan}
238 } else {
239 reporter.ensurePublished(ctx)
240 }
241 }()
242
243 return stream, nil
244}
245
246// CountTokens is not supported for GitHub Copilot.
247func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
248 return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"}
249}
250
251// Refresh validates the GitHub token is still working.
252// GitHub OAuth tokens don't expire traditionally, so we just validate.
253func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
254 if auth == nil {
255 return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
256 }
257
258 // Get the GitHub access token
259 accessToken := metaStringValue(auth.Metadata, "access_token")
260 if accessToken == "" {
261 return auth, nil
262 }
263
264 // Validate the token can still get a Copilot API token
265 copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
266 _, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
267 if err != nil {
268 return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)}
269 }
270
271 return auth, nil
272}
273
274// ensureAPIToken gets or refreshes the Copilot API token.
275func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
276 if auth == nil {
277 return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
278 }
279
280 // Check for cached API token
281 if cachedToken := metaStringValue(auth.Metadata, "copilot_api_token"); cachedToken != "" {
282 if expiresAt := tokenExpiry(auth.Metadata); expiresAt.After(time.Now().Add(5 * time.Minute)) {
283 return cachedToken, nil
284 }
285 }
286
287 // Get the GitHub access token
288 accessToken := metaStringValue(auth.Metadata, "access_token")
289 if accessToken == "" {
290 return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}
291 }
292
293 // Get a new Copilot API token
294 copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
295 apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
296 if err != nil {
297 return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)}
298 }
299
300 // Cache the token in metadata (will be persisted on next save)
301 if auth.Metadata == nil {
302 auth.Metadata = make(map[string]any)
303 }
304 auth.Metadata["copilot_api_token"] = apiToken.Token
305 if apiToken.ExpiresAt > 0 {
306 auth.Metadata["expired"] = time.Unix(apiToken.ExpiresAt, 0).Format(time.RFC3339)
307 } else {
308 auth.Metadata["expired"] = time.Now().Add(githubCopilotTokenCacheTTL).Format(time.RFC3339)
309 }
310
311 return apiToken.Token, nil
312}
313
314// applyHeaders sets the required headers for GitHub Copilot API requests.
315func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
316 r.Header.Set("Content-Type", "application/json")
317 r.Header.Set("Authorization", "Bearer "+apiToken)
318 r.Header.Set("Accept", "application/json")
319 r.Header.Set("User-Agent", "GithubCopilot/1.0")
320 r.Header.Set("Editor-Version", "vscode/1.100.0")
321 r.Header.Set("Editor-Plugin-Version", "copilot/1.300.0")
322 r.Header.Set("Openai-Intent", "conversation-panel")
323 r.Header.Set("Copilot-Integration-Id", "vscode-chat")
324 r.Header.Set("X-Request-Id", uuid.NewString())
325}
326
327// normalizeModel ensures the model name is correct for the API.
328func (e *GitHubCopilotExecutor) normalizeModel(requestedModel string, body []byte) []byte {
329 // Map friendly names to API model names
330 modelMap := map[string]string{
331 "gpt-4.1": "gpt-4.1",
332 "gpt-5": "gpt-5",
333 "gpt-5-mini": "gpt-5-mini",
334 "gpt-5-codex": "gpt-5-codex",
335 "gpt-5.1": "gpt-5.1",
336 "gpt-5.1-codex": "gpt-5.1-codex",
337 "gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
338 "claude-haiku-4.5": "claude-haiku-4.5",
339 "claude-opus-4.1": "claude-opus-4.1",
340 "claude-opus-4.5": "claude-opus-4.5",
341 "claude-sonnet-4": "claude-sonnet-4",
342 "claude-sonnet-4.5": "claude-sonnet-4.5",
343 "gemini-2.5-pro": "gemini-2.5-pro",
344 "gemini-3-pro": "gemini-3-pro",
345 "grok-code-fast-1": "grok-code-fast-1",
346 "raptor-mini": "raptor-mini",
347 }
348
349 if mapped, ok := modelMap[requestedModel]; ok {
350 body, _ = sjson.SetBytes(body, "model", mapped)
351 }
352
353 return body
354}