1package provider
2
3// GitHub Copilot client.
4//
5// Auth chain:
6//
7// 1. User stores a GitHub Personal Access Token (PAT) in
8// COPILOT_GITHUB_TOKEN (or via zot's auth.json, when wired). The PAT
9// is the long-lived credential; it never hits the inference endpoint.
10// 2. We trade the PAT for a short-lived Copilot token by hitting
11// `GET https://api.github.com/copilot_internal/v2/token` with the PAT
12// as `Authorization: Bearer` plus the Copilot identity headers
13// (Editor-Version, Editor-Plugin-Version, Copilot-Integration-Id,
14// User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.
15// 3. The short-lived token's value embeds a `proxy-ep=<host>` field that
16// tells us the real API host (individual users: api.individual.githubcopilot.com).
17// 4. Inference requests use Messages, Responses, or Chat Completions with the
18// short-lived token in `Authorization: Bearer` plus extras:
19// - X-Initiator: user|agent
20// - Openai-Intent: conversation-edits
21// - Copilot-Vision-Request: true (when images are present)
22//
23// Token caching: short-lived tokens last ~30min. We cache one per PAT in
24// memory for the process lifetime and refresh on demand. No disk cache.
25
26import (
27 "context"
28 "encoding/json"
29 "fmt"
30 "io"
31 "net/http"
32 "net/url"
33 "strings"
34 "sync"
35 "time"
36)
37
38// Copilot identity headers — these must match what the VS Code Copilot
39// extension sends or the proxy rejects with 401.
40var copilotIdentityHeaders = map[string]string{
41 "User-Agent": "GitHubCopilotChat/0.35.0",
42 "Editor-Version": "vscode/1.107.0",
43 "Editor-Plugin-Version": "copilot-chat/0.35.0",
44 "Copilot-Integration-Id": "vscode-chat",
45}
46
47type copilotToken struct {
48 value string
49 expiresAt time.Time
50 baseURL string
51}
52
53// copilotTokenCache is a process-wide cache of short-lived Copilot tokens
54// keyed by the user's PAT. Concurrency-safe; safe to call from multiple
55// goroutines (a single agent loop is sequential, but extension intercepts
56// can run in parallel).
57type copilotTokenCache struct {
58 mu sync.Mutex
59 tokens map[string]copilotToken
60 http *http.Client
61}
62
63var copilotCache = &copilotTokenCache{
64 tokens: map[string]copilotToken{},
65 http: &http.Client{Timeout: 30 * time.Second},
66}
67
68// exchange swaps a PAT for a short-lived Copilot token. Caller is
69// expected to hold no lock; we acquire/release internally.
70func (c *copilotTokenCache) exchange(ctx context.Context, pat string) (copilotToken, error) {
71 url := "https://api.github.com/copilot_internal/v2/token"
72 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
73 if err != nil {
74 return copilotToken{}, err
75 }
76 req.Header.Set("Authorization", "Bearer "+pat)
77 req.Header.Set("Accept", "application/json")
78 for k, v := range copilotIdentityHeaders {
79 req.Header.Set(k, v)
80 }
81 resp, err := c.http.Do(req)
82 if err != nil {
83 return copilotToken{}, fmt.Errorf("copilot token exchange: %w", err)
84 }
85 defer resp.Body.Close()
86 body, _ := io.ReadAll(resp.Body)
87 if resp.StatusCode != http.StatusOK {
88 return copilotToken{}, fmt.Errorf("copilot token exchange: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
89 }
90 var out struct {
91 Token string `json:"token"`
92 ExpiresAt int64 `json:"expires_at"`
93 }
94 if err := json.Unmarshal(body, &out); err != nil {
95 return copilotToken{}, fmt.Errorf("copilot token: parse: %w", err)
96 }
97 if out.Token == "" {
98 return copilotToken{}, fmt.Errorf("copilot token: empty")
99 }
100 base := parseCopilotProxyEndpoint(out.Token)
101 if base == "" {
102 base = "https://api.individual.githubcopilot.com"
103 }
104 exp := time.Unix(out.ExpiresAt, 0)
105 if exp.IsZero() || time.Until(exp) < 0 {
106 // Reference subtracts a 5min safety margin; we do the same so a
107 // long-running tool call doesn't blow past expiry mid-flight.
108 exp = time.Now().Add(25 * time.Minute)
109 } else {
110 exp = exp.Add(-5 * time.Minute)
111 }
112 return copilotToken{value: out.Token, expiresAt: exp, baseURL: base}, nil
113}
114
115// get returns a fresh-enough Copilot token, refreshing if needed.
116func (c *copilotTokenCache) get(ctx context.Context, pat string) (copilotToken, error) {
117 c.mu.Lock()
118 tok, ok := c.tokens[pat]
119 c.mu.Unlock()
120 if ok && time.Now().Before(tok.expiresAt) {
121 return tok, nil
122 }
123 fresh, err := c.exchange(ctx, pat)
124 if err != nil {
125 return copilotToken{}, err
126 }
127 c.mu.Lock()
128 c.tokens[pat] = fresh
129 c.mu.Unlock()
130 return fresh, nil
131}
132
133// parseCopilotProxyEndpoint extracts `proxy-ep=<host>` from a Copilot
134// short-lived token's value (it's not a JWT — it's an ad-hoc
135// semicolon-separated key=value string) and converts proxy.* -> api.*.
136// Returns "" if not found.
137func parseCopilotProxyEndpoint(token string) string {
138 for _, part := range strings.Split(token, ";") {
139 if strings.HasPrefix(part, "proxy-ep=") {
140 host := strings.TrimPrefix(part, "proxy-ep=")
141 host = strings.TrimPrefix(host, "proxy.")
142 return "https://api." + host
143 }
144 }
145 return ""
146}
147
148// copilotRefreshTransport wraps the default HTTP transport so every
149// outgoing inference request gets the latest short-lived Copilot token
150// in Authorization. Token exchange uses the PAT carried in the
151// transport itself, not the request.
152type copilotRefreshTransport struct {
153 inner http.RoundTripper
154 pat string
155}
156
157func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) {
158 tok, err := copilotCache.get(req.Context(), t.pat)
159 if err != nil {
160 return nil, err
161 }
162 clone := req.Clone(req.Context())
163 clone.Header.Set("Authorization", "Bearer "+tok.value)
164 // Messages uses Copilot Bearer auth, never an Anthropic API key.
165 clone.Header.Del("x-api-key")
166 // Identity headers also required on inference requests.
167 for k, v := range copilotIdentityHeaders {
168 clone.Header.Set(k, v)
169 }
170 if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok {
171 clone.Header.Set("X-Initiator", metadata.initiator)
172 if metadata.vision {
173 clone.Header.Set("Copilot-Vision-Request", "true")
174 } else {
175 clone.Header.Del("Copilot-Vision-Request")
176 }
177 clone.Header.Set("Openai-Intent", "conversation-edits")
178 }
179 // If the request URL host doesn't match the token's proxy-ep, rewrite
180 // it. The openaiClient pinned a static host at construction time, but
181 // the canonical host comes from the token.
182 if tok.baseURL != "" {
183 if u, err := url.Parse(tok.baseURL); err == nil && u.Host != clone.URL.Host {
184 clone.URL.Scheme = u.Scheme
185 clone.URL.Host = u.Host
186 clone.Host = u.Host
187 }
188 }
189 return t.inner.RoundTrip(clone)
190}
191
192// copilotResponsesStripTransport removes the codex client's ChatGPT
193// OAuth identity headers before handing off to copilotRefreshTransport,
194// which applies the Copilot token and identity headers. The Copilot
195// proxy rejects requests that carry chatgpt-account-id / openai-beta.
196type copilotResponsesStripTransport struct {
197 inner http.RoundTripper
198}
199
200func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Response, error) {
201 clone := req.Clone(req.Context())
202 clone.Header.Del("chatgpt-account-id")
203 clone.Header.Del("openai-beta")
204 clone.Header.Del("originator")
205 return t.inner.RoundTrip(clone)
206}
207
208const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com"
209
210// NewGithubCopilotClient uses Messages for Claude, Responses for GPT/Grok/MAI,
211// and Chat Completions for Gemini/Kimi. The PAT is exchanged for Copilot tokens.
212func NewGithubCopilotClient(pat string) Client {
213 refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}
214 completionsHTTP := &http.Client{Transport: refresh, Timeout: 0}
215 // Initial baseURL is a sane default; copilotRefreshTransport rewrites
216 // the host on every request based on the freshly-issued token.
217 completions := &openaiClient{
218 apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity
219 baseURL: copilotDefaultBaseURL,
220 chatCompletionsPath: "/chat/completions",
221 name: "github-copilot",
222 http: completionsHTTP,
223 }
224
225 responsesHTTP := &http.Client{
226 Transport: &copilotResponsesStripTransport{inner: refresh},
227 Timeout: 0,
228 }
229 responses := &codexClient{
230 token: pat, // unused at the wire level (transport overrides Auth) but kept for parity
231 baseURL: copilotDefaultBaseURL + "/responses",
232 errorLabel: "github copilot",
233 providerName: "github-copilot",
234 disableCLIRouting: true,
235 http: responsesHTTP,
236 }
237
238 messages := &anthropicClient{
239 name: "github-copilot",
240 baseURL: copilotDefaultBaseURL,
241 // Do not set oauthTok: that would inject Claude Code identity and rename tools.
242 http: &http.Client{Transport: refresh},
243 }
244 return &copilotClient{router: NewModelRouter("github-copilot", completions, map[string]Client{
245 APICompletions: completions,
246 APIResponses: responses,
247 APIAnthropicMessages: messages,
248 }).(*modelRouter)}
249}