Skip to content

Commit d8e3d4e

Browse files
committed
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
1 parent 26fc611 commit d8e3d4e

File tree

4 files changed

+205
-1
lines changed

4 files changed

+205
-1
lines changed

internal/auth/copilot/copilot_auth.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,86 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url
222222
return req, nil
223223
}
224224

225+
// CopilotModelEntry represents a single model entry returned by the Copilot /models API.
226+
type CopilotModelEntry struct {
227+
ID string `json:"id"`
228+
Object string `json:"object"`
229+
Created int64 `json:"created"`
230+
OwnedBy string `json:"owned_by"`
231+
Name string `json:"name,omitempty"`
232+
Version string `json:"version,omitempty"`
233+
Capabilities map[string]any `json:"capabilities,omitempty"`
234+
ModelExtra map[string]any `json:"-"` // catch-all for unknown fields
235+
}
236+
237+
// CopilotModelsResponse represents the response from the Copilot /models endpoint.
238+
type CopilotModelsResponse struct {
239+
Data []CopilotModelEntry `json:"data"`
240+
Object string `json:"object"`
241+
}
242+
243+
// ListModels fetches the list of available models from the Copilot API.
244+
// It requires a valid Copilot API token (not the GitHub access token).
245+
func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) {
246+
if apiToken == nil || apiToken.Token == "" {
247+
return nil, fmt.Errorf("copilot: api token is required for listing models")
248+
}
249+
250+
modelsURL := copilotAPIEndpoint + "/models"
251+
if apiToken.Endpoints.API != "" {
252+
modelsURL = apiToken.Endpoints.API + "/models"
253+
}
254+
255+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
256+
if err != nil {
257+
return nil, fmt.Errorf("copilot: failed to create models request: %w", err)
258+
}
259+
260+
req.Header.Set("Authorization", "Bearer "+apiToken.Token)
261+
req.Header.Set("Accept", "application/json")
262+
req.Header.Set("User-Agent", copilotUserAgent)
263+
req.Header.Set("Editor-Version", copilotEditorVersion)
264+
req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
265+
req.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
266+
267+
resp, err := c.httpClient.Do(req)
268+
if err != nil {
269+
return nil, fmt.Errorf("copilot: models request failed: %w", err)
270+
}
271+
defer func() {
272+
if errClose := resp.Body.Close(); errClose != nil {
273+
log.Errorf("copilot list models: close body error: %v", errClose)
274+
}
275+
}()
276+
277+
bodyBytes, err := io.ReadAll(resp.Body)
278+
if err != nil {
279+
return nil, fmt.Errorf("copilot: failed to read models response: %w", err)
280+
}
281+
282+
if !isHTTPSuccess(resp.StatusCode) {
283+
return nil, fmt.Errorf("copilot: list models failed with status %d: %s", resp.StatusCode, string(bodyBytes))
284+
}
285+
286+
var modelsResp CopilotModelsResponse
287+
if err = json.Unmarshal(bodyBytes, &modelsResp); err != nil {
288+
return nil, fmt.Errorf("copilot: failed to parse models response: %w", err)
289+
}
290+
291+
return modelsResp.Data, nil
292+
}
293+
294+
// ListModelsWithGitHubToken is a convenience method that exchanges a GitHub access token
295+
// for a Copilot API token and then fetches the available models.
296+
func (c *CopilotAuth) ListModelsWithGitHubToken(ctx context.Context, githubAccessToken string) ([]CopilotModelEntry, error) {
297+
apiToken, err := c.GetCopilotAPIToken(ctx, githubAccessToken)
298+
if err != nil {
299+
return nil, fmt.Errorf("copilot: failed to get API token for model listing: %w", err)
300+
}
301+
302+
return c.ListModels(ctx, apiToken)
303+
}
304+
225305
// buildChatCompletionURL builds the URL for chat completions API.
226306
func buildChatCompletionURL() string {
227307
return copilotAPIEndpoint + "/chat/completions"

internal/config/oauth_model_alias_defaults.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package config
22

3+
import "strings"
4+
35
// defaultKiroAliases returns default oauth-model-alias entries for Kiro.
46
// These aliases expose standard Claude IDs for Kiro-prefixed upstream models.
57
func defaultKiroAliases() []OAuthModelAlias {
@@ -35,3 +37,28 @@ func defaultGitHubCopilotAliases() []OAuthModelAlias {
3537
{Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true},
3638
}
3739
}
40+
41+
// GitHubCopilotAliasesFromModels generates oauth-model-alias entries from a dynamic
42+
// list of model IDs fetched from the Copilot API. It auto-creates aliases for
43+
// models whose ID contains a dot (e.g. "claude-opus-4.6" → "claude-opus-4-6"),
44+
// which is the pattern used by Claude models on Copilot.
45+
func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias {
46+
var aliases []OAuthModelAlias
47+
seen := make(map[string]struct{})
48+
for _, id := range modelIDs {
49+
if !strings.Contains(id, ".") {
50+
continue
51+
}
52+
hyphenID := strings.ReplaceAll(id, ".", "-")
53+
if hyphenID == id {
54+
continue
55+
}
56+
key := id + "→" + hyphenID
57+
if _, ok := seen[key]; ok {
58+
continue
59+
}
60+
seen[key] = struct{}{}
61+
aliases = append(aliases, OAuthModelAlias{Name: id, Alias: hyphenID, Fork: true})
62+
}
63+
return aliases
64+
}

internal/runtime/executor/github_copilot_executor.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/google/uuid"
1515
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
1616
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17+
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1718
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
1819
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
1920
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -1264,3 +1265,97 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
12641265
func isHTTPSuccess(statusCode int) bool {
12651266
return statusCode >= 200 && statusCode < 300
12661267
}
1268+
1269+
// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API.
1270+
// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token,
1271+
// then queries the /models endpoint. Falls back to the static registry on any failure.
1272+
func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
1273+
if auth == nil {
1274+
log.Debug("github-copilot: auth is nil, using static models")
1275+
return registry.GetGitHubCopilotModels()
1276+
}
1277+
1278+
accessToken := metaStringValue(auth.Metadata, "access_token")
1279+
if accessToken == "" {
1280+
log.Debug("github-copilot: no access_token in auth metadata, using static models")
1281+
return registry.GetGitHubCopilotModels()
1282+
}
1283+
1284+
copilotAuth := copilotauth.NewCopilotAuth(cfg)
1285+
1286+
apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
1287+
if err != nil {
1288+
log.Warnf("github-copilot: failed to get API token for model listing: %v, using static models", err)
1289+
return registry.GetGitHubCopilotModels()
1290+
}
1291+
1292+
entries, err := copilotAuth.ListModels(ctx, apiToken)
1293+
if err != nil {
1294+
log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err)
1295+
return registry.GetGitHubCopilotModels()
1296+
}
1297+
1298+
if len(entries) == 0 {
1299+
log.Debug("github-copilot: API returned no models, using static models")
1300+
return registry.GetGitHubCopilotModels()
1301+
}
1302+
1303+
// Build a lookup from the static definitions so we can enrich dynamic entries
1304+
// with known context lengths, thinking support, etc.
1305+
staticMap := make(map[string]*registry.ModelInfo)
1306+
for _, m := range registry.GetGitHubCopilotModels() {
1307+
staticMap[m.ID] = m
1308+
}
1309+
1310+
now := time.Now().Unix()
1311+
models := make([]*registry.ModelInfo, 0, len(entries))
1312+
for _, entry := range entries {
1313+
if entry.ID == "" {
1314+
continue
1315+
}
1316+
1317+
m := &registry.ModelInfo{
1318+
ID: entry.ID,
1319+
Object: "model",
1320+
Created: now,
1321+
OwnedBy: "github-copilot",
1322+
Type: "github-copilot",
1323+
}
1324+
1325+
if entry.Created > 0 {
1326+
m.Created = entry.Created
1327+
}
1328+
if entry.Name != "" {
1329+
m.DisplayName = entry.Name
1330+
} else {
1331+
m.DisplayName = entry.ID
1332+
}
1333+
1334+
// Enrich from capabilities if available
1335+
if caps, ok := entry.Capabilities["type"].(string); ok && caps != "" {
1336+
_ = caps // reserved for future use
1337+
}
1338+
1339+
// Merge known metadata from the static fallback list
1340+
if static, ok := staticMap[entry.ID]; ok {
1341+
if m.DisplayName == entry.ID && static.DisplayName != "" {
1342+
m.DisplayName = static.DisplayName
1343+
}
1344+
m.Description = static.Description
1345+
m.ContextLength = static.ContextLength
1346+
m.MaxCompletionTokens = static.MaxCompletionTokens
1347+
m.SupportedEndpoints = static.SupportedEndpoints
1348+
m.Thinking = static.Thinking
1349+
} else {
1350+
// Sensible defaults for models not in the static list
1351+
m.Description = entry.ID + " via GitHub Copilot"
1352+
m.ContextLength = 128000
1353+
m.MaxCompletionTokens = 16384
1354+
}
1355+
1356+
models = append(models, m)
1357+
}
1358+
1359+
log.Infof("github-copilot: fetched %d models from API", len(models))
1360+
return models
1361+
}

sdk/cliproxy/service.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
866866
models = registry.GetKimiModels()
867867
models = applyExcludedModels(models, excluded)
868868
case "github-copilot":
869-
models = registry.GetGitHubCopilotModels()
869+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
870+
models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg)
871+
cancel()
870872
models = applyExcludedModels(models, excluded)
871873
case "kiro":
872874
models = s.fetchKiroModels(a)

0 commit comments

Comments
 (0)