Feat/copilot login (#49)

* feat(auth): add Copilot login

Add experimental GitHub Copilot device login and copilot/* provider support for users with Copilot access but no OpenAI account.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): use responses for GPT-5

Route Copilot GPT-5 models through the Responses API because gpt-5.5 is not available on /chat/completions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): honor device flow timing

* docs(copilot): add auth helper docstrings

* fix(auth): address copilot review feedback

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Nuno do Carmo
2026-06-07 23:21:20 +02:00
committed by GitHub
parent e610bdd2d0
commit febdc530e1
11 changed files with 1107 additions and 17 deletions
+123 -4
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -9,11 +10,11 @@ import (
"time"
)
// CredentialStore holds all stored credentials for various providers.
// Currently supports Anthropic and OpenAI credentials with both OAuth and API key authentication methods.
// CredentialStore holds stored credentials for Anthropic, OpenAI, and GitHub Copilot.
type CredentialStore struct {
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
OpenAI *OpenAICredentials `json:"openai,omitempty"`
Copilot *CopilotCredentials `json:"copilot,omitempty"`
}
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
@@ -43,6 +44,16 @@ type OpenAICredentials struct {
CreatedAt time.Time `json:"created_at"`
}
// CopilotCredentials holds GitHub OAuth credentials and the short-lived
// GitHub Copilot API token derived from them.
type CopilotCredentials struct {
Type string `json:"type"` // "oauth"
GitHubToken string `json:"github_token,omitempty"` // GitHub device-flow OAuth token
CopilotAccessToken string `json:"copilot_access_token,omitempty"` // Short-lived Copilot API token
ExpiresAt int64 `json:"expires_at,omitempty"` // Copilot token expiry
CreatedAt time.Time `json:"created_at"`
}
// oauthTokenExpired reports whether an OAuth token with the given type and
// expiry unix timestamp is past its expiry. Returns false for API key
// credentials or when no expiry is set.
@@ -91,6 +102,16 @@ func (c *OpenAICredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// IsExpired checks if the Copilot API token is expired.
func (c *CopilotCredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh reports whether the Copilot API token should be renewed.
func (c *CopilotCredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
// It manages a JSON file stored in the user's config directory with appropriate
// file permissions for security.
@@ -222,7 +243,7 @@ func (cm *CredentialManager) RemoveAnthropicCredentials() error {
store.Anthropic = nil
// If store is empty, remove the file entirely
if store.Anthropic == nil {
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
@@ -279,7 +300,7 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
store.OpenAI = nil
// If store is empty, remove the file entirely
if store.Anthropic == nil && store.OpenAI == nil {
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
@@ -289,6 +310,104 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
return cm.SaveCredentials(store)
}
// GetCopilotCredentials retrieves stored GitHub Copilot credentials.
func (cm *CredentialManager) GetCopilotCredentials() (*CopilotCredentials, error) {
store, err := cm.LoadCredentials()
if err != nil {
return nil, err
}
return store.Copilot, nil
}
// RemoveCopilotCredentials removes stored GitHub Copilot credentials.
func (cm *CredentialManager) RemoveCopilotCredentials() error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.Copilot = nil
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
return nil
}
return cm.SaveCredentials(store)
}
// HasCopilotCredentials checks if valid GitHub Copilot credentials are stored.
func (cm *CredentialManager) HasCopilotCredentials() (bool, error) {
creds, err := cm.GetCopilotCredentials()
if err != nil {
return false, err
}
if creds == nil {
return false, nil
}
return creds.Type == "oauth" && creds.GitHubToken != "", nil
}
// SetCopilotOAuthCredentials stores GitHub Copilot OAuth credentials.
func (cm *CredentialManager) SetCopilotOAuthCredentials(creds *CopilotCredentials) error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.Copilot = creds
return cm.SaveCredentials(store)
}
// GetValidCopilotAccessToken returns a fresh Copilot API token, renewing it
// with the stored GitHub OAuth token when needed.
func (cm *CredentialManager) GetValidCopilotAccessToken() (string, error) {
return cm.GetValidCopilotAccessTokenContext(context.Background())
}
// GetValidCopilotAccessTokenContext returns a fresh Copilot API token, renewing
// it with the stored GitHub OAuth token when needed.
func (cm *CredentialManager) GetValidCopilotAccessTokenContext(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
creds, err := cm.GetCopilotCredentials()
if err != nil {
return "", err
}
if creds == nil {
return "", fmt.Errorf("no Copilot credentials found")
}
if creds.Type != "oauth" {
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
}
if creds.GitHubToken == "" {
return "", fmt.Errorf("GitHub OAuth token missing from Copilot credentials")
}
if creds.CopilotAccessToken == "" || creds.NeedsRefresh() {
client := NewCopilotOAuthClient()
newCreds, err := client.RefreshCopilotToken(ctx, creds.GitHubToken)
if err != nil {
return "", fmt.Errorf("failed to refresh Copilot token: %w", err)
}
newCreds.CreatedAt = creds.CreatedAt
if err := cm.SetCopilotOAuthCredentials(newCreds); err != nil {
return "", fmt.Errorf("failed to save refreshed Copilot token: %w", err)
}
return newCreds.CopilotAccessToken, nil
}
return creds.CopilotAccessToken, nil
}
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
// Returns true if either a non-empty OAuth access token or API key is present,
// false otherwise. Returns an error if credentials cannot be loaded.
+97
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCredentialManager(t *testing.T) {
@@ -215,6 +216,7 @@ func TestCredentialStorePersistence(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
credentialsPath := filepath.Join(tempDir, "credentials.json")
@@ -252,3 +254,98 @@ func TestCredentialStorePersistence(t *testing.T) {
t.Errorf("Expected file permissions 0600, got %v", info.Mode().Perm())
}
}
func TestCopilotCredentials(t *testing.T) {
tempDir, err := os.MkdirTemp("", "kit-auth-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
cm := &CredentialManager{
credentialsPath: filepath.Join(tempDir, "credentials.json"),
}
creds := &CopilotCredentials{
Type: "oauth",
GitHubToken: "github-token",
CopilotAccessToken: "copilot-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
CreatedAt: time.Now(),
}
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
}
hasAuth, err := cm.HasCopilotCredentials()
if err != nil {
t.Fatalf("HasCopilotCredentials failed: %v", err)
}
if !hasAuth {
t.Fatal("Expected Copilot credentials")
}
token, err := cm.GetValidCopilotAccessToken()
if err != nil {
t.Fatalf("GetValidCopilotAccessToken failed: %v", err)
}
if token != creds.CopilotAccessToken {
t.Fatalf("Expected Copilot token %q, got %q", creds.CopilotAccessToken, token)
}
if err := cm.RemoveCopilotCredentials(); err != nil {
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
}
hasAuth, err = cm.HasCopilotCredentials()
if err != nil {
t.Fatalf("HasCopilotCredentials after removal failed: %v", err)
}
if hasAuth {
t.Fatal("Expected no Copilot credentials after removal")
}
}
func TestRemoveCredentialsPreservesOtherProviders(t *testing.T) {
tempDir, err := os.MkdirTemp("", "kit-auth-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
cm := &CredentialManager{
credentialsPath: filepath.Join(tempDir, "credentials.json"),
}
if err := cm.SetOpenAIOAuthCredentials(&OpenAICredentials{
Type: "oauth",
AccessToken: "openai-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
AccountID: "account",
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("SetOpenAIOAuthCredentials failed: %v", err)
}
if err := cm.SetCopilotOAuthCredentials(&CopilotCredentials{
Type: "oauth",
GitHubToken: "github-token",
CopilotAccessToken: "copilot-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
}
if err := cm.RemoveCopilotCredentials(); err != nil {
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
}
hasOpenAI, err := cm.HasOpenAICredentials()
if err != nil {
t.Fatalf("HasOpenAICredentials failed: %v", err)
}
if !hasOpenAI {
t.Fatal("Expected OpenAI credentials to remain after removing Copilot credentials")
}
}
+257
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -211,6 +212,262 @@ type OpenAIOAuthClient struct {
Scopes string
}
// CopilotOAuthClient handles GitHub device-flow OAuth and exchanges the
// GitHub token for a short-lived GitHub Copilot API token.
//
// The GitHub token comes from GitHub's OAuth device flow. It is then presented
// to GitHub's internal Copilot token endpoint, which returns the bearer token
// used by api.githubcopilot.com.
type CopilotOAuthClient struct {
ClientID string
DeviceURL string
TokenURL string
CopilotURL string
Scopes string
PollTimeout time.Duration
ClientTimeout time.Duration
}
// CopilotDeviceCode contains data returned by GitHub's device-code endpoint.
type CopilotDeviceCode struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// NewCopilotOAuthClient creates a GitHub Copilot OAuth client.
func NewCopilotOAuthClient() *CopilotOAuthClient {
return &CopilotOAuthClient{
ClientID: "Iv1.b507a08c87ecfe98",
DeviceURL: "https://github.com/login/device/code",
TokenURL: "https://github.com/login/oauth/access_token",
CopilotURL: "https://api.github.com/copilot_internal/v2/token",
Scopes: "read:user",
PollTimeout: 15 * time.Minute,
ClientTimeout: 30 * time.Second,
}
}
// StartDeviceFlow requests a GitHub device code for browser login.
//
// The returned user code and verification URI are displayed by loginCopilot.
// GitHub's response may omit interval, so this method normalizes it to the
// documented five-second default.
func (c *CopilotOAuthClient) StartDeviceFlow(ctx context.Context) (*CopilotDeviceCode, error) {
if ctx == nil {
ctx = context.Background()
}
data := url.Values{
"client_id": {c.ClientID},
"scope": {c.Scopes},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.DeviceURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create device-code request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request device code: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("device-code request failed with status %d: %s", resp.StatusCode, string(body))
}
var code CopilotDeviceCode
if err := json.NewDecoder(resp.Body).Decode(&code); err != nil {
return nil, fmt.Errorf("failed to decode device-code response: %w", err)
}
if code.DeviceCode == "" || code.UserCode == "" || code.VerificationURI == "" {
return nil, fmt.Errorf("device-code response missing required fields")
}
if code.Interval <= 0 {
code.Interval = 5
}
return &code, nil
}
// PollDeviceToken waits until the user authorizes the device code and returns
// the resulting GitHub OAuth token.
//
// It follows GitHub's device-flow polling contract: authorization_pending keeps
// polling, slow_down increases the interval, and polling stops at the earlier of
// the client timeout or the device-code expiry.
func (c *CopilotOAuthClient) PollDeviceToken(ctx context.Context, deviceCode *CopilotDeviceCode) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if deviceCode == nil || deviceCode.DeviceCode == "" {
return "", fmt.Errorf("device code missing")
}
deadline := time.Now().Add(c.PollTimeout)
if deviceCode.ExpiresIn > 0 {
expiresAt := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
if expiresAt.Before(deadline) {
deadline = expiresAt
}
}
interval := time.Duration(deviceCode.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
for time.Now().Before(deadline) {
wait := interval
if remaining := time.Until(deadline); remaining < wait {
wait = remaining
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
data := url.Values{
"client_id": {c.ClientID},
"device_code": {deviceCode.DeviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create device-token request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return "", fmt.Errorf("failed to poll device token: %w", err)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
Description string `json:"error_description"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&tokenResp)
_ = resp.Body.Close()
if decodeErr != nil {
return "", fmt.Errorf("failed to decode device-token response: %w", decodeErr)
}
if tokenResp.AccessToken != "" {
return tokenResp.AccessToken, nil
}
switch tokenResp.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
continue
case "expired_token":
return "", fmt.Errorf("device code expired; restart login")
case "access_denied":
return "", fmt.Errorf("github login denied")
case "":
return "", fmt.Errorf("device-token request failed with status %d", resp.StatusCode)
default:
if tokenResp.Description != "" {
return "", fmt.Errorf("device-token request failed: %s: %s", tokenResp.Error, tokenResp.Description)
}
return "", fmt.Errorf("device-token request failed: %s", tokenResp.Error)
}
}
return "", fmt.Errorf("timed out waiting for github device authorization")
}
// ExchangeGitHubToken converts a GitHub OAuth token into a Copilot API token.
// It is a semantic wrapper over RefreshCopilotToken used by the login flow.
func (c *CopilotOAuthClient) ExchangeGitHubToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
return c.RefreshCopilotToken(ctx, githubToken)
}
// RefreshCopilotToken obtains a fresh short-lived Copilot token from GitHub.
//
// GitHub may return expires_at as either a Unix timestamp or RFC3339 string.
// parseCopilotExpiry handles both forms and falls back to a conservative
// 20-minute lifetime when the field is absent or unrecognized.
func (c *CopilotOAuthClient) RefreshCopilotToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
if ctx == nil {
ctx = context.Background()
}
req, err := http.NewRequestWithContext(ctx, "GET", c.CopilotURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create copilot token request: %w", err)
}
req.Header.Set("Authorization", "token "+githubToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "kit")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request copilot token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("copilot token request failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
Token string `json:"token"`
ExpiresAt any `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode copilot token response: %w", err)
}
if tokenResp.Token == "" {
return nil, fmt.Errorf("copilot token response missing token")
}
expiresAt := parseCopilotExpiry(tokenResp.ExpiresAt)
if expiresAt == 0 {
expiresAt = time.Now().Add(20 * time.Minute).Unix()
}
return &CopilotCredentials{
Type: "oauth",
GitHubToken: githubToken,
CopilotAccessToken: tokenResp.Token,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}, nil
}
// parseCopilotExpiry normalizes GitHub's expires_at variants to a Unix second.
func parseCopilotExpiry(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case string:
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil {
return parsed
}
if parsed, err := time.Parse(time.RFC3339, v); err == nil {
return parsed.Unix()
}
}
return 0
}
// NewOpenAIOAuthClient creates a new OAuth client configured for OpenAI Codex OAuth.
// This uses the public client ID for CLI applications with PKCE for security.
func NewOpenAIOAuthClient() *OpenAIOAuthClient {
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCopilotStartDeviceFlow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm failed: %v", err)
}
if r.Form.Get("client_id") != "client-id" {
t.Fatalf("expected client id, got %q", r.Form.Get("client_id"))
}
if r.Form.Get("scope") != "read:user" {
t.Fatalf("expected scope, got %q", r.Form.Get("scope"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"device_code": "device-code",
"user_code": "USER-CODE",
"verification_uri": "https://github.com/login/device",
"expires_in": 600,
"interval": 1,
})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.ClientID = "client-id"
client.DeviceURL = server.URL
code, err := client.StartDeviceFlow(context.Background())
if err != nil {
t.Fatalf("StartDeviceFlow failed: %v", err)
}
if code.DeviceCode != "device-code" || code.UserCode != "USER-CODE" || code.Interval != 1 {
t.Fatalf("unexpected device code: %#v", code)
}
}
func TestCopilotPollDeviceToken(t *testing.T) {
polls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
polls++
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm failed: %v", err)
}
if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" {
t.Fatalf("unexpected grant type: %q", r.Form.Get("grant_type"))
}
if polls == 1 {
_ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "github-token"})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.ClientID = "client-id"
client.TokenURL = server.URL
client.PollTimeout = 5 * time.Second
client.ClientTimeout = time.Second
token, err := client.PollDeviceToken(context.Background(), &CopilotDeviceCode{
DeviceCode: "device-code",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("PollDeviceToken failed: %v", err)
}
if token != "github-token" {
t.Fatalf("expected github-token, got %q", token)
}
if polls != 2 {
t.Fatalf("expected 2 polls, got %d", polls)
}
}
func TestCopilotRefreshToken(t *testing.T) {
expiresAt := time.Now().Add(time.Hour).Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.Header.Get("Authorization") != "token github-token" {
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
}
if r.Header.Get("User-Agent") != "kit" {
t.Fatalf("unexpected user agent: %q", r.Header.Get("User-Agent"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "copilot-token",
"expires_at": expiresAt,
})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.CopilotURL = server.URL
creds, err := client.RefreshCopilotToken(context.Background(), "github-token")
if err != nil {
t.Fatalf("RefreshCopilotToken failed: %v", err)
}
if creds.GitHubToken != "github-token" || creds.CopilotAccessToken != "copilot-token" {
t.Fatalf("unexpected credentials: %#v", creds)
}
if creds.ExpiresAt != expiresAt {
t.Fatalf("expected expires_at %d, got %d", expiresAt, creds.ExpiresAt)
}
}
+84
View File
@@ -0,0 +1,84 @@
package models
import (
"net/http"
"testing"
"time"
)
func TestCopilotProviderAliasUsesCatalog(t *testing.T) {
registry := NewModelsRegistry()
models, err := registry.GetModelsForProvider("copilot")
if err != nil {
t.Fatalf("GetModelsForProvider(copilot) failed: %v", err)
}
if len(models) == 0 {
t.Fatal("expected copilot alias to return github-copilot catalog models")
}
if registry.LookupModel("copilot", "gpt-5.5") == nil {
t.Fatal("expected copilot/gpt-5.5 to resolve through github-copilot catalog")
}
if registry.GetProviderInfo("copilot") == nil {
t.Fatal("expected copilot alias to return github-copilot provider info")
}
}
func TestCopilotRejectsNonGPTModels(t *testing.T) {
_, err := CreateProvider(t.Context(), &ProviderConfig{ModelString: "copilot/claude-sonnet-4.6"})
if err == nil {
t.Fatal("expected non-GPT Copilot model to be rejected")
}
}
func TestCopilotHTTPClientCachesToken(t *testing.T) {
client := createCopilotHTTPClient("cached-token", time.Now().Add(time.Hour).Unix(), false)
transport, ok := client.Transport.(*copilotTransport)
if !ok {
t.Fatal("expected *copilotTransport")
}
token := transport.cachedToken(t.Context())
if token != "cached-token" {
t.Fatalf("expected cached token, got %q", token)
}
}
func TestCopilotTransportHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
transport := &copilotTransport{
base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Authorization") != "Bearer cached-token" {
t.Fatalf("unexpected Authorization header: %q", req.Header.Get("Authorization"))
}
if req.Header.Get("Copilot-Integration-Id") != copilotIntegrationID {
t.Fatalf("unexpected Copilot-Integration-Id header: %q", req.Header.Get("Copilot-Integration-Id"))
}
if req.Header.Get("Editor-Version") != copilotEditorVersion {
t.Fatalf("unexpected Editor-Version header: %q", req.Header.Get("Editor-Version"))
}
if req.Header.Get("User-Agent") != copilotUserAgent {
t.Fatalf("unexpected User-Agent header: %q", req.Header.Get("User-Agent"))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}),
token: "cached-token",
expiresAt: time.Now().Add(time.Hour).Unix(),
}
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip failed: %v", err)
}
_ = resp.Body.Close()
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+201 -6
View File
@@ -13,6 +13,7 @@ import (
"os"
"regexp"
"strings"
"sync"
"time"
"charm.land/fantasy"
@@ -33,6 +34,24 @@ import (
const (
// ClaudeCodePrompt is the required system prompt for OAuth authentication.
ClaudeCodePrompt = "You are Claude Code, Anthropic's official CLI for Claude."
// copilotProviderID is the canonical models.dev provider key. The CLI also
// accepts the shorter "copilot" alias for user-facing model strings.
copilotProviderID = "github-copilot"
// copilotAliasProviderID is the short provider prefix accepted by kit.
copilotAliasProviderID = "copilot"
// copilotBaseURL is the fallback API URL if the model catalog has no API URL.
copilotBaseURL = "https://api.githubcopilot.com"
// GitHub Copilot currently expects VS Code Copilot Chat client identifiers.
// Keep these centralized so they are easy to audit and update when GitHub
// changes accepted client metadata.
copilotIntegrationID = "vscode-chat"
copilotEditorVersion = "vscode/1.104.1"
copilotEditorPluginVersion = "copilot-chat/0.31.0"
copilotUserAgent = "GitHubCopilotChat/0.31.0"
copilotOpenAIIntent = "conversation-agent"
copilotGitHubAPIVersion = "2026-01-09"
)
// resolveModelAlias resolves model aliases to their full names using the registry
@@ -215,6 +234,20 @@ func ParseModelString(modelString string) (provider, model string, err error) {
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
}
// isCopilotProvider reports whether provider is the canonical catalog key or
// the user-facing shorthand alias.
func isCopilotProvider(provider string) bool {
return provider == copilotAliasProviderID || provider == copilotProviderID
}
// catalogProviderID maps supported provider aliases to their models.dev keys.
func catalogProviderID(provider string) string {
if isCopilotProvider(provider) {
return copilotProviderID
}
return provider
}
// CreateProvider creates a fantasy LanguageModel based on the provider configuration.
// Model metadata is looked up from the models.dev database for cost tracking and
// capability detection, but unknown models are passed through to the provider
@@ -238,17 +271,30 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
}
registry := GetGlobalRegistry()
lookupProvider := catalogProviderID(provider)
// Look up model metadata (advisory, not blocking).
// Look up model metadata (advisory for most providers, strict for Copilot).
// When the model is known we validate config limits and print
// suggestions on likely typos; when unknown we let the provider
// API be the authority.
modelInfo := registry.LookupModel(provider, modelName)
if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
// API be the authority except for Copilot, whose non-GPT catalog entries
// require unsupported wire protocols.
modelInfo := registry.LookupModel(lookupProvider, modelName)
if isCopilotProvider(provider) {
providerInfo := registry.GetProviderInfo(copilotProviderID)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", copilotProviderID)
}
if modelInfo == nil {
if suggestions := registry.SuggestModels(copilotProviderID, modelName); len(suggestions) > 0 {
return nil, fmt.Errorf("model %q not found for provider %s. Did you mean one of: %s", modelName, copilotProviderID, strings.Join(suggestions, ", "))
}
return nil, fmt.Errorf("model %q not found for provider %s", modelName, copilotProviderID)
}
} else if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
// Model not in database — warn with suggestions but don't block.
if suggestions := registry.SuggestModels(provider, modelName); len(suggestions) > 0 {
if suggestions := registry.SuggestModels(lookupProvider, modelName); len(suggestions) > 0 {
fmt.Fprintf(os.Stderr, "Warning: model %q not found in model database for provider %s. Similar models: %s\n",
modelName, provider, strings.Join(suggestions, ", "))
modelName, lookupProvider, strings.Join(suggestions, ", "))
}
}
@@ -282,6 +328,8 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
result, createErr = createAnthropicProvider(ctx, config, modelName)
case "openai":
result, createErr = createOpenAIProvider(ctx, config, modelName)
case "copilot", "github-copilot":
result, createErr = createCopilotProvider(ctx, config, modelName)
case "google", "gemini":
result, createErr = createGoogleProvider(ctx, config, modelName)
case "ollama":
@@ -1023,6 +1071,72 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// createCopilotProvider builds a GitHub Copilot provider through fantasy's
// OpenAI-compatible provider. The catalog key is github-copilot, but the public
// model prefix may be either copilot/ or github-copilot/.
//
// Only gpt-* Copilot models are enabled here. The catalog also lists Claude and
// Gemini Copilot models, but those require different wire protocols and must be
// routed explicitly before they can be safely accepted.
func createCopilotProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
if !strings.HasPrefix(modelName, "gpt-") {
return nil, fmt.Errorf("GitHub Copilot model %q is not supported yet: only gpt-* models use the OpenAI-compatible protocol", modelName)
}
cm, err := auth.NewCredentialManager()
if err != nil {
return nil, fmt.Errorf("failed to initialize credential manager: %w", err)
}
token, err := cm.GetValidCopilotAccessTokenContext(ctx)
if err != nil {
return nil, fmt.Errorf("GitHub Copilot credentials not available. Use 'kit auth login copilot': %w", err)
}
expiresAt := int64(0)
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == token {
expiresAt = creds.ExpiresAt
}
baseURL := copilotBaseURL
if providerInfo := GetGlobalRegistry().GetProviderInfo(copilotProviderID); providerInfo != nil && providerInfo.API != "" {
baseURL = providerInfo.API
}
if config.ProviderURL != "" {
baseURL = config.ProviderURL
}
opts := []openai.Option{
openai.WithName(copilotAliasProviderID),
openai.WithBaseURL(baseURL),
openai.WithAPIKey(token),
openai.WithHTTPClient(createCopilotHTTPClient(token, expiresAt, config.TLSSkipVerify)),
openai.WithUseResponsesAPI(),
openai.WithResponsesAPIFunc(copilotUsesResponsesAPI),
openai.WithObjectMode(fantasy.ObjectModeTool),
}
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub Copilot provider: %w", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub Copilot model: %w", err)
}
providerOpts := buildOpenAIProviderOptions(config, modelName)
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// copilotUsesResponsesAPI selects the OpenAI Responses API for Copilot models
// known to support it. Non-gpt models are rejected before provider creation.
func copilotUsesResponsesAPI(modelID string) bool {
return strings.HasPrefix(modelID, "gpt-5")
}
// createOpenAICodexProvider creates a provider for ChatGPT/Codex OAuth tokens.
// Uses the chatgpt.com/backend-api/codex endpoint with special headers.
func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, modelName, token, accountID string) (*ProviderResult, error) {
@@ -1152,6 +1266,87 @@ func (t *codexTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(newReq)
}
// createCopilotHTTPClient returns an HTTP client that injects Copilot-specific
// authorization and client metadata headers. The token and expiry are cached in
// the transport so streaming requests do not hit credentials.json on every
// RoundTrip; the credential manager is consulted only near expiry.
func createCopilotHTTPClient(token string, expiresAt int64, skipVerify bool) *http.Client {
var base http.RoundTripper
if skipVerify {
base = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
} else {
base = http.DefaultTransport
}
return &http.Client{
Transport: &copilotTransport{
base: base,
token: token,
expiresAt: expiresAt,
},
Timeout: 120 * time.Second,
}
}
// copilotTransport decorates requests for api.githubcopilot.com.
//
// It owns a cached Copilot access token. When the token is still valid, the hot
// path is in-memory only. Near expiry it refreshes through CredentialManager,
// which updates both the cache here and credentials.json.
type copilotTransport struct {
base http.RoundTripper
token string
expiresAt int64
mu sync.Mutex
}
func (t *copilotTransport) RoundTrip(req *http.Request) (*http.Response, error) {
token := t.cachedToken(req.Context())
newReq := req.Clone(req.Context())
newReq.Header.Set("Authorization", "Bearer "+token)
newReq.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
newReq.Header.Set("Editor-Version", copilotEditorVersion)
newReq.Header.Set("Editor-Plugin-Version", copilotEditorPluginVersion)
newReq.Header.Set("Openai-Intent", copilotOpenAIIntent)
newReq.Header.Set("User-Agent", copilotUserAgent)
newReq.Header.Set("X-GitHub-Api-Version", copilotGitHubAPIVersion)
return t.base.RoundTrip(newReq)
}
// cachedToken returns the cached token unless it is within the five-minute
// refresh window. Refresh errors fall back to the last token so the request can
// surface any authoritative auth failure from the Copilot API.
func (t *copilotTransport) cachedToken(ctx context.Context) string {
t.mu.Lock()
defer t.mu.Unlock()
if t.expiresAt == 0 || time.Now().Unix() < t.expiresAt-300 {
return t.token
}
cm, err := auth.NewCredentialManager()
if err != nil {
return t.token
}
fresh, err := cm.GetValidCopilotAccessTokenContext(ctx)
if err != nil || fresh == "" {
return t.token
}
t.token = fresh
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == fresh {
t.expiresAt = creds.ExpiresAt
}
return t.token
}
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
apiKey := firstNonEmpty(
config.ProviderAPIKey,
+15
View File
@@ -246,6 +246,7 @@ func loadEmbeddedProviders() map[string]modelsDBProvider {
// doesn't track yet. Callers should treat a nil return as "unknown model"
// and continue with sensible defaults.
func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil
@@ -273,6 +274,7 @@ func LookupModelForSettings(modelString string) *ModelInfo {
// getRequiredEnvVars returns the required environment variables for a provider.
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
@@ -287,6 +289,7 @@ func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
// variables. Returns nil for providers not in the registry (unknown
// providers are assumed to handle auth themselves or via --provider-api-key).
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
provider = catalogProviderID(provider)
if apiKey != "" {
return nil
}
@@ -311,6 +314,15 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
}
}
// For GitHub Copilot, check stored GitHub OAuth credentials.
if provider == copilotProviderID {
if cm, err := auth.NewCredentialManager(); err == nil {
if has, _ := cm.HasCopilotCredentials(); has {
return nil
}
}
}
envVars, err := r.getRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
@@ -350,6 +362,7 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
// SuggestModels returns similar model names when an invalid model is provided.
func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil
@@ -415,6 +428,7 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// GetModelsForProvider returns all models for a specific provider.
func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
@@ -425,6 +439,7 @@ func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]Model
// GetProviderInfo returns the full provider info, or nil if not found.
func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
provider = catalogProviderID(provider)
info, exists := r.providers[provider]
if !exists {
return nil