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