main.go

  1// Package main implements a tool to fetch GitHub Copilot models and generate a Catwalk provider configuration.
  2package main
  3
  4import (
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"net/http"
 10	"os"
 11	"path/filepath"
 12	"regexp"
 13	"runtime"
 14	"slices"
 15	"strings"
 16	"time"
 17
 18	"charm.land/catwalk/pkg/catwalk"
 19)
 20
 21type Response struct {
 22	Object string  `json:"object"`
 23	Data   []Model `json:"data"`
 24}
 25
 26type APITokenResponse struct {
 27	Token     string                    `json:"token"`
 28	ExpiresAt int64                     `json:"expires_at"`
 29	Endpoints APITokenResponseEndpoints `json:"endpoints"`
 30}
 31
 32type APITokenResponseEndpoints struct {
 33	API string `json:"api"`
 34}
 35
 36type APIToken struct {
 37	APIKey      string
 38	ExpiresAt   time.Time
 39	APIEndpoint string
 40}
 41
 42type Model struct {
 43	ID                 string     `json:"id"`
 44	Name               string     `json:"name"`
 45	Version            string     `json:"version"`
 46	Vendor             string     `json:"vendor"`
 47	Preview            bool       `json:"preview"`
 48	ModelPickerEnabled bool       `json:"model_picker_enabled"`
 49	Capabilities       Capability `json:"capabilities"`
 50	Policy             *Policy    `json:"policy,omitempty"`
 51}
 52
 53type Capability struct {
 54	Family    string   `json:"family"`
 55	Type      string   `json:"type"`
 56	Tokenizer string   `json:"tokenizer"`
 57	Limits    Limits   `json:"limits"`
 58	Supports  Supports `json:"supports"`
 59}
 60
 61type Limits struct {
 62	MaxContextWindowTokens int `json:"max_context_window_tokens,omitempty"`
 63	MaxOutputTokens        int `json:"max_output_tokens,omitempty"`
 64	MaxPromptTokens        int `json:"max_prompt_tokens,omitempty"`
 65}
 66
 67type Supports struct {
 68	ToolCalls         bool `json:"tool_calls,omitempty"`
 69	ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
 70	Vision            bool `json:"vision,omitempty"`
 71}
 72
 73type Policy struct {
 74	State string `json:"state"`
 75	Terms string `json:"terms"`
 76}
 77
 78var versionedModelRegexp = regexp.MustCompile(`-\d{4}-\d{2}-\d{2}$`)
 79
 80func main() {
 81	if err := run(); err != nil {
 82		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 83		os.Exit(1)
 84	}
 85}
 86
 87func run() error {
 88	copilotModels, err := fetchCopilotModels()
 89	if err != nil {
 90		return err
 91	}
 92
 93	// NOTE(@andreynering): Exclude versioned models and keep only the main version of each.
 94	copilotModels = slices.DeleteFunc(copilotModels, func(m Model) bool {
 95		return m.ID != m.Version || versionedModelRegexp.MatchString(m.ID) || strings.Contains(m.ID, "embedding")
 96	})
 97
 98	catwalkModels := modelsToCatwalk(copilotModels)
 99	slices.SortStableFunc(catwalkModels, func(a, b catwalk.Model) int {
100		return strings.Compare(a.ID, b.ID)
101	})
102
103	provider := catwalk.Provider{
104		ID:                  catwalk.InferenceProviderCopilot,
105		Name:                "GitHub Copilot",
106		Models:              catwalkModels,
107		APIEndpoint:         "https://api.githubcopilot.com",
108		Type:                catwalk.TypeOpenAICompat,
109		DefaultLargeModelID: "claude-sonnet-4.5",
110		DefaultSmallModelID: "claude-haiku-4.5",
111	}
112	data, err := json.MarshalIndent(provider, "", "  ")
113	if err != nil {
114		return fmt.Errorf("unable to marshal json: %w", err)
115	}
116	data = append(data, '\n')
117	if err := os.WriteFile("internal/providers/configs/copilot.json", data, 0o600); err != nil {
118		return fmt.Errorf("unable to write copilog.json: %w", err)
119	}
120	return nil
121}
122
123func fetchCopilotModels() ([]Model, error) {
124	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
125	defer cancel()
126
127	oauthToken := copilotToken()
128	if oauthToken == "" {
129		return nil, fmt.Errorf("no OAuth token available")
130	}
131
132	// Step 1: Fetch API token from the token endpoint
133	tokenURL := "https://api.github.com/copilot_internal/v2/token" //nolint:gosec
134	tokenReq, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil)
135	if err != nil {
136		return nil, fmt.Errorf("unable to create token request: %w", err)
137	}
138	tokenReq.Header.Set("Accept", "application/json")
139	tokenReq.Header.Set("Authorization", fmt.Sprintf("token %s", oauthToken))
140
141	// Use approved integration ID to bypass client check
142	tokenReq.Header.Set("Copilot-Integration-Id", "vscode-chat")
143	tokenReq.Header.Set("User-Agent", "GitHubCopilotChat/0.1")
144
145	client := &http.Client{}
146	tokenResp, err := client.Do(tokenReq)
147	if err != nil {
148		return nil, fmt.Errorf("unable to make token request: %w", err)
149	}
150	defer tokenResp.Body.Close() //nolint:errcheck
151
152	tokenBody, err := io.ReadAll(tokenResp.Body)
153	if err != nil {
154		return nil, fmt.Errorf("unable to read token response body: %w", err)
155	}
156
157	if tokenResp.StatusCode != http.StatusOK {
158		return nil, fmt.Errorf("unexpected status code from token endpoint: %d", tokenResp.StatusCode)
159	}
160
161	var tokenData APITokenResponse
162	if err := json.Unmarshal(tokenBody, &tokenData); err != nil {
163		return nil, fmt.Errorf("unable to unmarshal token response: %w", err)
164	}
165
166	// Convert to APIToken
167	expiresAt := time.Unix(tokenData.ExpiresAt, 0)
168	apiToken := APIToken{
169		APIKey:      tokenData.Token,
170		ExpiresAt:   expiresAt,
171		APIEndpoint: tokenData.Endpoints.API,
172	}
173
174	// Step 2: Use the dynamic endpoint from the token to fetch models
175	modelsURL := apiToken.APIEndpoint + "/models"
176	modelsReq, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil)
177	if err != nil {
178		return nil, fmt.Errorf("unable to create models request: %w", err)
179	}
180	modelsReq.Header.Set("Accept", "application/json")
181	modelsReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken.APIKey))
182	modelsReq.Header.Set("Copilot-Integration-Id", "vscode-chat")
183	modelsReq.Header.Set("User-Agent", "GitHubCopilotChat/0.1")
184
185	modelsResp, err := client.Do(modelsReq)
186	if err != nil {
187		return nil, fmt.Errorf("unable to make models request: %w", err)
188	}
189	defer modelsResp.Body.Close() //nolint:errcheck
190
191	modelsBody, err := io.ReadAll(modelsResp.Body)
192	if err != nil {
193		return nil, fmt.Errorf("unable to read models response body: %w", err)
194	}
195
196	if modelsResp.StatusCode != http.StatusOK {
197		return nil, fmt.Errorf("unexpected status code from models endpoint: %d", modelsResp.StatusCode)
198	}
199
200	// for debugging
201	_ = os.MkdirAll("tmp", 0o700)
202	_ = os.WriteFile("tmp/copilot-response.json", modelsBody, 0o600)
203
204	var data Response
205	if err := json.Unmarshal(modelsBody, &data); err != nil {
206		return nil, fmt.Errorf("unable to unmarshal json: %w", err)
207	}
208	return data.Data, nil
209}
210
211func modelsToCatwalk(m []Model) []catwalk.Model {
212	models := make([]catwalk.Model, 0, len(m))
213	for _, model := range m {
214		models = append(models, modelToCatwalk(model))
215	}
216	return models
217}
218
219func modelToCatwalk(m Model) catwalk.Model {
220	return catwalk.Model{
221		ID:               m.ID,
222		Name:             m.Name,
223		DefaultMaxTokens: int64(m.Capabilities.Limits.MaxOutputTokens),
224		ContextWindow:    int64(m.Capabilities.Limits.MaxContextWindowTokens),
225		SupportsImages:   m.Capabilities.Supports.Vision,
226	}
227}
228
229func copilotToken() string {
230	if token := os.Getenv("COPILOT_TOKEN"); token != "" {
231		return token
232	}
233	return tokenFromDisk()
234}
235
236func tokenFromDisk() string {
237	data, err := os.ReadFile(tokenFilePath())
238	if err != nil {
239		return ""
240	}
241	var content map[string]struct {
242		User        string `json:"user"`
243		OAuthToken  string `json:"oauth_token"`
244		GitHubAppID string `json:"githubAppId"`
245	}
246	if err := json.Unmarshal(data, &content); err != nil {
247		return ""
248	}
249	if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok {
250		return app.OAuthToken
251	}
252	return ""
253}
254
255func tokenFilePath() string {
256	switch runtime.GOOS {
257	case "windows":
258		return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json")
259	default:
260		return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json")
261	}
262}