diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 6dfc44aa01982d04f754bd60e9ce83e7a91cc22c..73231271e9a855796af21cb6c2e624cb1c88798d 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -19,6 +19,7 @@ jobs: go-version-file: go.mod - name: Generate provider configurations run: | + go run ./cmd/copilot/main.go go run ./cmd/openrouter/main.go go run ./cmd/synthetic/main.go # we need to add this back when we know that the providers/models all work diff --git a/cmd/copilot/main.go b/cmd/copilot/main.go new file mode 100644 index 0000000000000000000000000000000000000000..38389a97176d17a93f550593480b037c8fa720cd --- /dev/null +++ b/cmd/copilot/main.go @@ -0,0 +1,188 @@ +// Package main implements a tool to fetch GitHub Copilot models and generate a Catwalk provider configuration. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "time" + + "github.com/charmbracelet/catwalk/pkg/catwalk" +) + +type Response struct { + Object string `json:"object"` + Data []Model `json:"data"` +} + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Vendor string `json:"vendor"` + Preview bool `json:"preview"` + ModelPickerEnabled bool `json:"model_picker_enabled"` + Capabilities Capability `json:"capabilities"` + Policy *Policy `json:"policy,omitempty"` +} + +type Capability struct { + Family string `json:"family"` + Type string `json:"type"` + Tokenizer string `json:"tokenizer"` + Limits Limits `json:"limits"` + Supports Supports `json:"supports"` +} + +type Limits struct { + MaxContextWindowTokens int `json:"max_context_window_tokens,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + MaxPromptTokens int `json:"max_prompt_tokens,omitempty"` +} + +type Supports struct { + ToolCalls bool `json:"tool_calls,omitempty"` + ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` +} + +type Policy struct { + State string `json:"state"` + Terms string `json:"terms"` +} + +var versionedModelRegexp = regexp.MustCompile(`-\d{4}-\d{2}-\d{2}$`) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + copilotModels, err := fetchCopilotModels() + if err != nil { + return err + } + + // NOTE(@andreynering): Exclude versioned models and keep only the main version of each. + copilotModels = slices.DeleteFunc(copilotModels, func(m Model) bool { + return m.ID != m.Version || versionedModelRegexp.MatchString(m.ID) || strings.Contains(m.ID, "embedding") + }) + + catwalkModels := modelsToCatwalk(copilotModels) + slices.SortStableFunc(catwalkModels, func(a, b catwalk.Model) int { + return strings.Compare(a.ID, b.ID) + }) + + provider := catwalk.Provider{ + ID: catwalk.InferenceProviderCopilot, + Name: "GitHub Copilot", + Models: catwalkModels, + APIEndpoint: "https://api.githubcopilot.com", + Type: catwalk.TypeOpenAICompat, + DefaultLargeModelID: "claude-sonnet-4.5", + DefaultSmallModelID: "claude-haiku-4.5", + } + data, err := json.MarshalIndent(provider, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal json: %w", err) + } + if err := os.WriteFile("internal/providers/configs/copilot.json", data, 0o600); err != nil { + return fmt.Errorf("unable to write copilog.json: %w", err) + } + return nil +} + +func fetchCopilotModels() ([]Model, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + "GET", + "https://api.githubcopilot.com/models", + nil, + ) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", copilotToken())) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to make http request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var data Response + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("unable to unmarshal json: %w", err) + } + return data.Data, nil +} + +func modelsToCatwalk(m []Model) []catwalk.Model { + models := make([]catwalk.Model, 0, len(m)) + for _, model := range m { + models = append(models, modelToCatwalk(model)) + } + return models +} + +func modelToCatwalk(m Model) catwalk.Model { + return catwalk.Model{ + ID: m.ID, + Name: m.Name, + DefaultMaxTokens: int64(m.Capabilities.Limits.MaxOutputTokens), + ContextWindow: int64(m.Capabilities.Limits.MaxContextWindowTokens), + } +} + +func copilotToken() string { + if token := os.Getenv("COPILOT_TOKEN"); token != "" { + return token + } + return tokenFromDisk() +} + +func tokenFromDisk() string { + data, err := os.ReadFile(tokenFilePath()) + if err != nil { + return "" + } + var content map[string]struct { + User string `json:"user"` + OAuthToken string `json:"oauth_token"` + GitHubAppID string `json:"githubAppId"` + } + if err := json.Unmarshal(data, &content); err != nil { + return "" + } + if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok { + return app.OAuthToken + } + return "" +} + +func tokenFilePath() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json") + default: + return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json") + } +} diff --git a/internal/providers/configs/copilot.json b/internal/providers/configs/copilot.json new file mode 100644 index 0000000000000000000000000000000000000000..16daac7ebfd706393d6980930208740f115bdf6d --- /dev/null +++ b/internal/providers/configs/copilot.json @@ -0,0 +1,127 @@ +{ + "name": "GitHub Copilot", + "id": "copilot", + "api_endpoint": "https://api.githubcopilot.com", + "type": "openai-compat", + "default_large_model_id": "claude-sonnet-4.5", + "default_small_model_id": "claude-haiku-4.5", + "models": [ + { + "id": "claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 144000, + "default_max_tokens": 16000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 216000, + "default_max_tokens": 16000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 144000, + "default_max_tokens": 16000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 128000, + "default_max_tokens": 64000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "gpt-3.5-turbo-0613", + "name": "GPT 3.5 Turbo", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 16384, + "default_max_tokens": 4096, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "gpt-4-0613", + "name": "GPT 4", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 32768, + "default_max_tokens": 4096, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "gpt-5", + "name": "GPT-5", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 400000, + "default_max_tokens": 128000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "gpt-5-mini", + "name": "GPT-5 mini", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 264000, + "default_max_tokens": 64000, + "can_reason": false, + "supports_attachments": false, + "options": {} + }, + { + "id": "grok-code-fast-1", + "name": "Grok Code Fast 1", + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 128000, + "default_max_tokens": 64000, + "can_reason": false, + "supports_attachments": false, + "options": {} + } + ] +} \ No newline at end of file diff --git a/internal/providers/providers.go b/internal/providers/providers.go index c24a9768e182e1f1e07c05147c57819a6031ccf1..d5299c49a1dacb6882ca49e053251651344c6a8d 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -63,6 +63,9 @@ var aiHubMixConfig []byte //go:embed configs/kimi.json var kimiCodingConfig []byte +//go:embed configs/copilot.json +var copilotConfig []byte + // ProviderFunc is a function that returns a Provider. type ProviderFunc func() catwalk.Provider @@ -85,6 +88,7 @@ var providerRegistry = []ProviderFunc{ huggingFaceProvider, aiHubMixProvider, syntheticProvider, + copilotProvider, } // GetAll returns all registered providers. @@ -176,3 +180,7 @@ func aiHubMixProvider() catwalk.Provider { func kimiCodingProvider() catwalk.Provider { return loadProviderFromConfig(kimiCodingConfig) } + +func copilotProvider() catwalk.Provider { + return loadProviderFromConfig(copilotConfig) +} diff --git a/pkg/catwalk/provider.go b/pkg/catwalk/provider.go index 2f803782a03d2ce7e437ab4131f83dcb806a1d18..d390f08e9ca28600e694ccbc1672aec0b2818bf4 100644 --- a/pkg/catwalk/provider.go +++ b/pkg/catwalk/provider.go @@ -37,6 +37,7 @@ const ( InferenceProviderHuggingFace InferenceProvider = "huggingface" InferenceAIHubMix InferenceProvider = "aihubmix" InferenceKimiCoding InferenceProvider = "kimi-coding" + InferenceProviderCopilot InferenceProvider = "copilot" ) // Provider represents an AI provider configuration. @@ -99,6 +100,7 @@ func KnownProviders() []InferenceProvider { InferenceProviderHuggingFace, InferenceAIHubMix, InferenceKimiCoding, + InferenceProviderCopilot, } }