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 @@
1+package executor
2+
3+import (
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+
22+const (
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.
30+type GitHubCopilotExecutor struct {
31+ cfg *config.Config
32+}
33+
34+// NewGitHubCopilotExecutor constructs a new executor instance.
35+func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
36+ return &GitHubCopilotExecutor{cfg: cfg}
37+}
38+
39+// Identifier implements ProviderExecutor.
40+func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
41+
42+// PrepareRequest implements ProviderExecutor.
43+func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
44+ return nil
45+}
46+
47+// Execute handles non-streaming requests to GitHub Copilot.
48+func (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.
131+func (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.
247+func (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.
253+func (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.
275+func (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.
315+func (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.
328+func (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+}