Files
localmodelroutex/backend/internal/providers/copilot/copilot.go
LovroG05 4d57318e86 ci: run tests, lint and the UI build on every push
Add a golangci-lint config and fix its findings: the OIDC state cookie
gets the Secure flag on https deployments, and pool sizes are clamped
before the int32 conversion.
2026-10-04 17:32:44 +02:00

144 lines
3.6 KiB
Go

// Package copilot implements the GitHub Copilot provider. Copilot's chat API is
// OpenAI-compatible, but auth requires exchanging a GitHub token (PAT/OAuth) for
// a short-lived Copilot token, refreshed under a mutex as it nears expiry.
package copilot
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"git.govekar.net/LovroG05/localmodelroutex/internal/providers"
"git.govekar.net/LovroG05/localmodelroutex/internal/providers/openaicompat"
)
const (
tokenURL = "https://api.github.com/copilot_internal/v2/token" //nolint:gosec // G101: a URL, not a credential
chatBaseURL = "https://api.githubcopilot.com"
// refreshSkew refreshes the token slightly before it actually expires.
refreshSkew = time.Minute
)
type provider struct {
*openaicompat.Transport
name string
}
// New builds a Copilot provider. Credentials: {"pat": "<github token>"};
// "token" and "api_key" are accepted as aliases.
func New(cfg providers.Config) (providers.Provider, error) {
var sec struct {
PAT string `json:"pat"`
Token string `json:"token"`
APIKey string `json:"api_key"`
}
if err := providers.DecodeCredentials(cfg, &sec); err != nil {
return nil, err
}
pat := sec.PAT
if pat == "" {
pat = sec.Token
}
if pat == "" {
pat = sec.APIKey
}
if pat == "" {
return nil, errors.New("copilot: missing github token (credentials.api_key)")
}
base := cfg.BaseURL
if base == "" {
base = chatBaseURL
}
ts := &tokenSource{pat: pat, http: cfg.HTTPClient}
return &provider{
name: cfg.Name,
Transport: &openaicompat.Transport{
Provider: cfg.Name,
BaseURL: base,
HTTP: cfg.HTTPClient,
Authorize: ts.authorize,
IncludeUsage: true,
Headers: http.Header{
"Editor-Version": {"vscode/1.95.0"},
"Copilot-Integration-Id": {"vscode-chat"},
},
},
}, nil
}
func (p *provider) Name() string { return p.name }
func (p *provider) Type() string { return "github_copilot" }
func (p *provider) Capabilities() []providers.Capability {
return []providers.Capability{providers.CapabilityChat}
}
// tokenSource exchanges a GitHub token for a Copilot session token and caches it
// until shortly before expiry.
type tokenSource struct {
pat string
http *http.Client
mu sync.Mutex
token string
exp time.Time
}
func (ts *tokenSource) authorize(ctx context.Context, req *http.Request) error {
tok, err := ts.get(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
return nil
}
func (ts *tokenSource) get(ctx context.Context) (string, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.token != "" && time.Now().Before(ts.exp.Add(-refreshSkew)) {
return ts.token, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+ts.pat)
req.Header.Set("Accept", "application/json")
client := ts.http
if client == nil {
client = providers.DefaultHTTPClient()
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("copilot token exchange: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("copilot token exchange: status %d: %s", resp.StatusCode, b)
}
var out struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("copilot token exchange: decode: %w", err)
}
ts.token = out.Token
ts.exp = time.Unix(out.ExpiresAt, 0)
return ts.token, nil
}
func init() { providers.Register("github_copilot", New) }