v0.3.74 0f485af

github_copilot.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close