v0.2.66 d5769b9

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 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		}
179	}
180	return t.inner.RoundTrip(clone)
181}
182
183// NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client.
184// The pat must be a GitHub Personal Access Token with Copilot access.
185func NewGithubCopilotClient(pat string) Client {
186	httpClient := &http.Client{
187		Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat},
188		Timeout:   0,
189	}
190	// Initial baseURL is a sane default; copilotRefreshTransport rewrites
191	// the host on every request based on the freshly-issued token.
192	return &openaiClient{
193		apiKey:  pat, // unused at the wire level (transport overrides Auth) but kept for parity
194		baseURL: "https://api.individual.githubcopilot.com",
195		name:    "github-copilot",
196		http:    httpClient,
197	}
198}

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