From d8e3d4e2b6d6a124893b01a684582b603b3094cf Mon Sep 17 00:00:00 2001 From: yx-bot7 Date: Wed, 4 Mar 2026 14:29:28 +0800 Subject: [PATCH 1/2] feat: dynamic model fetching for GitHub Copilot - Add ListModels/ListModelsWithGitHubToken to CopilotAuth for querying the /models endpoint at api.githubcopilot.com - Add FetchGitHubCopilotModels in executor with static fallback on failure - Update service.go to use dynamic fetching (15s timeout) instead of hardcoded GetGitHubCopilotModels() - Add GitHubCopilotAliasesFromModels for auto-generating dot-to-hyphen model aliases from dynamic model lists --- internal/auth/copilot/copilot_auth.go | 80 ++++++++++++++++ internal/config/oauth_model_alias_defaults.go | 27 ++++++ .../executor/github_copilot_executor.go | 95 +++++++++++++++++++ sdk/cliproxy/service.go | 4 +- 4 files changed, 205 insertions(+), 1 deletion(-) diff --git a/internal/auth/copilot/copilot_auth.go b/internal/auth/copilot/copilot_auth.go index 5776648c..e5308521 100644 --- a/internal/auth/copilot/copilot_auth.go +++ b/internal/auth/copilot/copilot_auth.go @@ -222,6 +222,86 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url return req, nil } +// CopilotModelEntry represents a single model entry returned by the Copilot /models API. +type CopilotModelEntry struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Capabilities map[string]any `json:"capabilities,omitempty"` + ModelExtra map[string]any `json:"-"` // catch-all for unknown fields +} + +// CopilotModelsResponse represents the response from the Copilot /models endpoint. +type CopilotModelsResponse struct { + Data []CopilotModelEntry `json:"data"` + Object string `json:"object"` +} + +// ListModels fetches the list of available models from the Copilot API. +// It requires a valid Copilot API token (not the GitHub access token). +func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) { + if apiToken == nil || apiToken.Token == "" { + return nil, fmt.Errorf("copilot: api token is required for listing models") + } + + modelsURL := copilotAPIEndpoint + "/models" + if apiToken.Endpoints.API != "" { + modelsURL = apiToken.Endpoints.API + "/models" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil) + if err != nil { + return nil, fmt.Errorf("copilot: failed to create models request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+apiToken.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", copilotUserAgent) + req.Header.Set("Editor-Version", copilotEditorVersion) + req.Header.Set("Editor-Plugin-Version", copilotPluginVersion) + req.Header.Set("Copilot-Integration-Id", copilotIntegrationID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("copilot: models request failed: %w", err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("copilot list models: close body error: %v", errClose) + } + }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("copilot: failed to read models response: %w", err) + } + + if !isHTTPSuccess(resp.StatusCode) { + return nil, fmt.Errorf("copilot: list models failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var modelsResp CopilotModelsResponse + if err = json.Unmarshal(bodyBytes, &modelsResp); err != nil { + return nil, fmt.Errorf("copilot: failed to parse models response: %w", err) + } + + return modelsResp.Data, nil +} + +// ListModelsWithGitHubToken is a convenience method that exchanges a GitHub access token +// for a Copilot API token and then fetches the available models. +func (c *CopilotAuth) ListModelsWithGitHubToken(ctx context.Context, githubAccessToken string) ([]CopilotModelEntry, error) { + apiToken, err := c.GetCopilotAPIToken(ctx, githubAccessToken) + if err != nil { + return nil, fmt.Errorf("copilot: failed to get API token for model listing: %w", err) + } + + return c.ListModels(ctx, apiToken) +} + // buildChatCompletionURL builds the URL for chat completions API. func buildChatCompletionURL() string { return copilotAPIEndpoint + "/chat/completions" diff --git a/internal/config/oauth_model_alias_defaults.go b/internal/config/oauth_model_alias_defaults.go index bb8329aa..16665713 100644 --- a/internal/config/oauth_model_alias_defaults.go +++ b/internal/config/oauth_model_alias_defaults.go @@ -1,5 +1,7 @@ package config +import "strings" + // defaultKiroAliases returns default oauth-model-alias entries for Kiro. // These aliases expose standard Claude IDs for Kiro-prefixed upstream models. func defaultKiroAliases() []OAuthModelAlias { @@ -35,3 +37,28 @@ func defaultGitHubCopilotAliases() []OAuthModelAlias { {Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true}, } } + +// GitHubCopilotAliasesFromModels generates oauth-model-alias entries from a dynamic +// list of model IDs fetched from the Copilot API. It auto-creates aliases for +// models whose ID contains a dot (e.g. "claude-opus-4.6" → "claude-opus-4-6"), +// which is the pattern used by Claude models on Copilot. +func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias { + var aliases []OAuthModelAlias + seen := make(map[string]struct{}) + for _, id := range modelIDs { + if !strings.Contains(id, ".") { + continue + } + hyphenID := strings.ReplaceAll(id, ".", "-") + if hyphenID == id { + continue + } + key := id + "→" + hyphenID + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, OAuthModelAlias{Name: id, Alias: hyphenID, Fork: true}) + } + return aliases +} diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index d22c2e7e..39df90c4 100644 --- a/internal/runtime/executor/github_copilot_executor.go +++ b/internal/runtime/executor/github_copilot_executor.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" @@ -1264,3 +1265,97 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st func isHTTPSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 } + +// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API. +// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token, +// then queries the /models endpoint. Falls back to the static registry on any failure. +func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + if auth == nil { + log.Debug("github-copilot: auth is nil, using static models") + return registry.GetGitHubCopilotModels() + } + + accessToken := metaStringValue(auth.Metadata, "access_token") + if accessToken == "" { + log.Debug("github-copilot: no access_token in auth metadata, using static models") + return registry.GetGitHubCopilotModels() + } + + copilotAuth := copilotauth.NewCopilotAuth(cfg) + + apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken) + if err != nil { + log.Warnf("github-copilot: failed to get API token for model listing: %v, using static models", err) + return registry.GetGitHubCopilotModels() + } + + entries, err := copilotAuth.ListModels(ctx, apiToken) + if err != nil { + log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err) + return registry.GetGitHubCopilotModels() + } + + if len(entries) == 0 { + log.Debug("github-copilot: API returned no models, using static models") + return registry.GetGitHubCopilotModels() + } + + // Build a lookup from the static definitions so we can enrich dynamic entries + // with known context lengths, thinking support, etc. + staticMap := make(map[string]*registry.ModelInfo) + for _, m := range registry.GetGitHubCopilotModels() { + staticMap[m.ID] = m + } + + now := time.Now().Unix() + models := make([]*registry.ModelInfo, 0, len(entries)) + for _, entry := range entries { + if entry.ID == "" { + continue + } + + m := ®istry.ModelInfo{ + ID: entry.ID, + Object: "model", + Created: now, + OwnedBy: "github-copilot", + Type: "github-copilot", + } + + if entry.Created > 0 { + m.Created = entry.Created + } + if entry.Name != "" { + m.DisplayName = entry.Name + } else { + m.DisplayName = entry.ID + } + + // Enrich from capabilities if available + if caps, ok := entry.Capabilities["type"].(string); ok && caps != "" { + _ = caps // reserved for future use + } + + // Merge known metadata from the static fallback list + if static, ok := staticMap[entry.ID]; ok { + if m.DisplayName == entry.ID && static.DisplayName != "" { + m.DisplayName = static.DisplayName + } + m.Description = static.Description + m.ContextLength = static.ContextLength + m.MaxCompletionTokens = static.MaxCompletionTokens + m.SupportedEndpoints = static.SupportedEndpoints + m.Thinking = static.Thinking + } else { + // Sensible defaults for models not in the static list + m.Description = entry.ID + " via GitHub Copilot" + m.ContextLength = 128000 + m.MaxCompletionTokens = 16384 + } + + models = append(models, m) + } + + log.Infof("github-copilot: fetched %d models from API", len(models)) + return models +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 536d8ae7..ae815104 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -866,7 +866,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = registry.GetKimiModels() models = applyExcludedModels(models, excluded) case "github-copilot": - models = registry.GetGitHubCopilotModels() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg) + cancel() models = applyExcludedModels(models, excluded) case "kiro": models = s.fetchKiroModels(a) From 7d6660d18172eb859f0330c7fa70da1b15a9ed21 Mon Sep 17 00:00:00 2001 From: yx-bot7 Date: Wed, 4 Mar 2026 15:43:51 +0800 Subject: [PATCH 2/2] fix: address PR review feedback - Fix SSRF: validate API endpoint host against allowlist before use - Limit /models response body to 2MB to prevent memory exhaustion (DoS) - Use MakeAuthenticatedRequest for consistent headers across API calls - Trim trailing slash on API endpoint to prevent double-slash URLs - Use ListModelsWithGitHubToken to simplify token exchange + listing - Deduplicate model IDs to prevent incorrect registry reference counting - Remove dead capabilities enrichment code block - Remove unused ModelExtra field with misleading json:"-" tag - Extract magic numbers to named constants (defaultCopilotContextLength) - Remove redundant hyphenID == id check (already filtered by Contains) - Use defer cancel() for context timeout in service.go --- internal/auth/copilot/copilot_auth.go | 51 ++++++++++++------- internal/config/oauth_model_alias_defaults.go | 3 -- .../executor/github_copilot_executor.go | 30 ++++++----- sdk/cliproxy/service.go | 2 +- 4 files changed, 49 insertions(+), 37 deletions(-) diff --git a/internal/auth/copilot/copilot_auth.go b/internal/auth/copilot/copilot_auth.go index e5308521..72f5e4e1 100644 --- a/internal/auth/copilot/copilot_auth.go +++ b/internal/auth/copilot/copilot_auth.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -224,14 +226,13 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url // CopilotModelEntry represents a single model entry returned by the Copilot /models API. type CopilotModelEntry struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - OwnedBy string `json:"owned_by"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - Capabilities map[string]any `json:"capabilities,omitempty"` - ModelExtra map[string]any `json:"-"` // catch-all for unknown fields + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Capabilities map[string]any `json:"capabilities,omitempty"` } // CopilotModelsResponse represents the response from the Copilot /models endpoint. @@ -240,6 +241,17 @@ type CopilotModelsResponse struct { Object string `json:"object"` } +// maxModelsResponseSize is the maximum allowed response size from the /models endpoint (2 MB). +const maxModelsResponseSize = 2 * 1024 * 1024 + +// allowedCopilotAPIHosts is the set of hosts that are considered safe for Copilot API requests. +var allowedCopilotAPIHosts = map[string]bool{ + "api.githubcopilot.com": true, + "api.individual.githubcopilot.com": true, + "api.business.githubcopilot.com": true, + "copilot-proxy.githubusercontent.com": true, +} + // ListModels fetches the list of available models from the Copilot API. // It requires a valid Copilot API token (not the GitHub access token). func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) { @@ -247,23 +259,22 @@ func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) return nil, fmt.Errorf("copilot: api token is required for listing models") } + // Build models URL, validating the endpoint host to prevent SSRF. modelsURL := copilotAPIEndpoint + "/models" - if apiToken.Endpoints.API != "" { - modelsURL = apiToken.Endpoints.API + "/models" + if ep := strings.TrimRight(apiToken.Endpoints.API, "/"); ep != "" { + parsed, err := url.Parse(ep) + if err == nil && parsed.Scheme == "https" && allowedCopilotAPIHosts[parsed.Host] { + modelsURL = ep + "/models" + } else { + log.Warnf("copilot: ignoring untrusted API endpoint %q, using default", ep) + } } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil) + req, err := c.MakeAuthenticatedRequest(ctx, http.MethodGet, modelsURL, nil, apiToken) if err != nil { return nil, fmt.Errorf("copilot: failed to create models request: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiToken.Token) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", copilotUserAgent) - req.Header.Set("Editor-Version", copilotEditorVersion) - req.Header.Set("Editor-Plugin-Version", copilotPluginVersion) - req.Header.Set("Copilot-Integration-Id", copilotIntegrationID) - resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("copilot: models request failed: %w", err) @@ -274,7 +285,9 @@ func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) } }() - bodyBytes, err := io.ReadAll(resp.Body) + // Limit response body to prevent memory exhaustion. + limitedReader := io.LimitReader(resp.Body, maxModelsResponseSize) + bodyBytes, err := io.ReadAll(limitedReader) if err != nil { return nil, fmt.Errorf("copilot: failed to read models response: %w", err) } diff --git a/internal/config/oauth_model_alias_defaults.go b/internal/config/oauth_model_alias_defaults.go index 16665713..5eda56ab 100644 --- a/internal/config/oauth_model_alias_defaults.go +++ b/internal/config/oauth_model_alias_defaults.go @@ -50,9 +50,6 @@ func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias { continue } hyphenID := strings.ReplaceAll(id, ".", "-") - if hyphenID == id { - continue - } key := id + "→" + hyphenID if _, ok := seen[key]; ok { continue diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index 39df90c4..dc23df97 100644 --- a/internal/runtime/executor/github_copilot_executor.go +++ b/internal/runtime/executor/github_copilot_executor.go @@ -1266,6 +1266,13 @@ func isHTTPSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 } +const ( + // defaultCopilotContextLength is the default context window for unknown Copilot models. + defaultCopilotContextLength = 128000 + // defaultCopilotMaxCompletionTokens is the default max output tokens for unknown Copilot models. + defaultCopilotMaxCompletionTokens = 16384 +) + // FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API. // It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token, // then queries the /models endpoint. Falls back to the static registry on any failure. @@ -1283,13 +1290,7 @@ func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg copilotAuth := copilotauth.NewCopilotAuth(cfg) - apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken) - if err != nil { - log.Warnf("github-copilot: failed to get API token for model listing: %v, using static models", err) - return registry.GetGitHubCopilotModels() - } - - entries, err := copilotAuth.ListModels(ctx, apiToken) + entries, err := copilotAuth.ListModelsWithGitHubToken(ctx, accessToken) if err != nil { log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err) return registry.GetGitHubCopilotModels() @@ -1309,10 +1310,16 @@ func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg now := time.Now().Unix() models := make([]*registry.ModelInfo, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) for _, entry := range entries { if entry.ID == "" { continue } + // Deduplicate model IDs to avoid incorrect reference counting. + if _, dup := seen[entry.ID]; dup { + continue + } + seen[entry.ID] = struct{}{} m := ®istry.ModelInfo{ ID: entry.ID, @@ -1331,11 +1338,6 @@ func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg m.DisplayName = entry.ID } - // Enrich from capabilities if available - if caps, ok := entry.Capabilities["type"].(string); ok && caps != "" { - _ = caps // reserved for future use - } - // Merge known metadata from the static fallback list if static, ok := staticMap[entry.ID]; ok { if m.DisplayName == entry.ID && static.DisplayName != "" { @@ -1349,8 +1351,8 @@ func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg } else { // Sensible defaults for models not in the static list m.Description = entry.ID + " via GitHub Copilot" - m.ContextLength = 128000 - m.MaxCompletionTokens = 16384 + m.ContextLength = defaultCopilotContextLength + m.MaxCompletionTokens = defaultCopilotMaxCompletionTokens } models = append(models, m) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index ae815104..885e5625 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -867,8 +867,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = applyExcludedModels(models, excluded) case "github-copilot": ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg) - cancel() models = applyExcludedModels(models, excluded) case "kiro": models = s.fetchKiroModels(a)