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	// Exclude versioned/duplicate models and keep only the main version of each.
 94	aliasedVersions := make(map[string]bool, len(copilotModels))
 95	for _, m := range copilotModels {
 96		if m.ID != m.Version {
 97			aliasedVersions[m.Version] = true
 98		}
 99	}
100
101	copilotModels = slices.DeleteFunc(copilotModels, func(m Model) bool {
102		return aliasedVersions[m.ID] || versionedModelRegexp.MatchString(m.ID) || strings.Contains(m.ID, "embedding") || strings.HasPrefix(m.ID, "accounts/msft/routers") || strings.HasPrefix(m.ID, "oswe-vscode") || m.ID == "gpt-4-o-preview"
103	})
104
105	// Deduplicate by ID (API can return duplicates)
106	seen := make(map[string]bool, len(copilotModels))
107	copilotModels = slices.DeleteFunc(copilotModels, func(m Model) bool {
108		if seen[m.ID] {
109			return true
110		}
111		seen[m.ID] = true
112		return false
113	})
114
115	catwalkModels := modelsToCatwalk(copilotModels)
116	slices.SortStableFunc(catwalkModels, func(a, b catwalk.Model) int {
117		return strings.Compare(a.ID, b.ID)
118	})
119
120	provider := catwalk.Provider{
121		ID:                  catwalk.InferenceProviderCopilot,
122		Name:                "GitHub Copilot",
123		Models:              catwalkModels,
124		APIEndpoint:         "https://api.githubcopilot.com",
125		Type:                catwalk.TypeOpenAICompat,
126		DefaultLargeModelID: "claude-sonnet-4.6",
127		DefaultSmallModelID: "claude-haiku-4.5",
128	}
129	data, err := json.MarshalIndent(provider, "", "  ")
130	if err != nil {
131		return fmt.Errorf("unable to marshal json: %w", err)
132	}
133	data = append(data, '\n')
134	if err := os.WriteFile("internal/providers/configs/copilot.json", data, 0o600); err != nil {
135		return fmt.Errorf("unable to write copilog.json: %w", err)
136	}
137	return nil
138}
139
140func fetchCopilotModels() ([]Model, error) {
141	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
142	defer cancel()
143
144	oauthToken := copilotToken()
145	if oauthToken == "" {
146		return nil, fmt.Errorf("no OAuth token available")
147	}
148
149	// Step 1: Fetch API token from the token endpoint
150	tokenURL := "https://api.github.com/copilot_internal/v2/token" //nolint:gosec
151	tokenReq, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil)
152	if err != nil {
153		return nil, fmt.Errorf("unable to create token request: %w", err)
154	}
155	tokenReq.Header.Set("Accept", "application/json")
156	tokenReq.Header.Set("Authorization", fmt.Sprintf("token %s", oauthToken))
157
158	// Use approved integration ID to bypass client check
159	tokenReq.Header.Set("Copilot-Integration-Id", "vscode-chat")
160	tokenReq.Header.Set("User-Agent", "GitHubCopilotChat/0.1")
161
162	client := &http.Client{}
163	tokenResp, err := client.Do(tokenReq)
164	if err != nil {
165		return nil, fmt.Errorf("unable to make token request: %w", err)
166	}
167	defer tokenResp.Body.Close() //nolint:errcheck
168
169	tokenBody, err := io.ReadAll(tokenResp.Body)
170	if err != nil {
171		return nil, fmt.Errorf("unable to read token response body: %w", err)
172	}
173
174	if tokenResp.StatusCode != http.StatusOK {
175		return nil, fmt.Errorf("unexpected status code from token endpoint: %d", tokenResp.StatusCode)
176	}
177
178	var tokenData APITokenResponse
179	if err := json.Unmarshal(tokenBody, &tokenData); err != nil {
180		return nil, fmt.Errorf("unable to unmarshal token response: %w", err)
181	}
182
183	// Convert to APIToken
184	expiresAt := time.Unix(tokenData.ExpiresAt, 0)
185	apiToken := APIToken{
186		APIKey:      tokenData.Token,
187		ExpiresAt:   expiresAt,
188		APIEndpoint: tokenData.Endpoints.API,
189	}
190
191	// Step 2: Use the dynamic endpoint from the token to fetch models
192	modelsURL := apiToken.APIEndpoint + "/models"
193	modelsReq, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil)
194	if err != nil {
195		return nil, fmt.Errorf("unable to create models request: %w", err)
196	}
197	modelsReq.Header.Set("Accept", "application/json")
198	modelsReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken.APIKey))
199	modelsReq.Header.Set("Copilot-Integration-Id", "vscode-chat")
200	modelsReq.Header.Set("User-Agent", "GitHubCopilotChat/0.1")
201
202	modelsResp, err := client.Do(modelsReq)
203	if err != nil {
204		return nil, fmt.Errorf("unable to make models request: %w", err)
205	}
206	defer modelsResp.Body.Close() //nolint:errcheck
207
208	modelsBody, err := io.ReadAll(modelsResp.Body)
209	if err != nil {
210		return nil, fmt.Errorf("unable to read models response body: %w", err)
211	}
212
213	if modelsResp.StatusCode != http.StatusOK {
214		return nil, fmt.Errorf("unexpected status code from models endpoint: %d", modelsResp.StatusCode)
215	}
216
217	// for debugging
218	_ = os.MkdirAll("tmp", 0o700)
219	_ = os.WriteFile("tmp/copilot-response.json", modelsBody, 0o600)
220
221	var data Response
222	if err := json.Unmarshal(modelsBody, &data); err != nil {
223		return nil, fmt.Errorf("unable to unmarshal json: %w", err)
224	}
225	return data.Data, nil
226}
227
228func modelsToCatwalk(m []Model) []catwalk.Model {
229	models := make([]catwalk.Model, 0, len(m))
230	for _, model := range m {
231		models = append(models, modelToCatwalk(model))
232	}
233	return models
234}
235
236func modelToCatwalk(m Model) catwalk.Model {
237	return catwalk.Model{
238		ID:               m.ID,
239		Name:             m.Name,
240		DefaultMaxTokens: int64(m.Capabilities.Limits.MaxOutputTokens),
241		ContextWindow:    int64(m.Capabilities.Limits.MaxContextWindowTokens),
242		SupportsImages:   m.Capabilities.Supports.Vision,
243	}
244}
245
246func copilotToken() string {
247	if token := os.Getenv("COPILOT_TOKEN"); token != "" {
248		return token
249	}
250	return tokenFromDisk()
251}
252
253func tokenFromDisk() string {
254	data, err := os.ReadFile(tokenFilePath())
255	if err != nil {
256		return ""
257	}
258	var content map[string]struct {
259		User        string `json:"user"`
260		OAuthToken  string `json:"oauth_token"`
261		GitHubAppID string `json:"githubAppId"`
262	}
263	if err := json.Unmarshal(data, &content); err != nil {
264		return ""
265	}
266	if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok {
267		return app.OAuthToken
268	}
269	if app, ok := content["github.com:Iv1.b507a08c87ecfe98:zed"]; ok {
270		return app.OAuthToken
271	}
272	return ""
273}
274
275func tokenFilePath() string {
276	switch runtime.GOOS {
277	case "windows":
278		return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json")
279	default:
280		return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json")
281	}
282}