From bcd2208b513d4ee115f9e96556e78c4c60d524c2 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 10 Feb 2026 23:34:19 +0300 Subject: [PATCH] fix(auth): strip model suffix in GitHub Copilot executor before upstream call GitHub Copilot API rejects model names with suffixes (e.g. claude-opus-4.6(medium)). The OAuthModelAlias resolution correctly maps aliases like 'opus(medium)' to 'claude-opus-4.6(medium)' preserving the suffix, but the executor must strip the suffix before sending to the upstream API since Copilot only accepts bare model names. Update normalizeModel in github_copilot_executor to strip suffixes using thinking.ParseSuffix, matching the pattern used by other executors. Also add test coverage for: - OAuthModelAliasChannel github-copilot and kiro channel resolution - Suffix preservation in alias resolution for github-copilot - normalizeModel suffix stripping in github_copilot_executor --- .../executor/github_copilot_executor.go | 12 +++-- .../executor/github_copilot_executor_test.go | 54 +++++++++++++++++++ sdk/cliproxy/auth/oauth_model_alias_test.go | 36 +++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/executor/github_copilot_executor_test.go diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index b43e1909..3681faf8 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/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -471,9 +472,14 @@ func detectVisionContent(body []byte) bool { return false } -// normalizeModel is a no-op as GitHub Copilot accepts model names directly. -// Model mapping should be done at the registry level if needed. -func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte { +// normalizeModel strips the suffix (e.g. "(medium)") from the model name +// before sending to GitHub Copilot, as the upstream API does not accept +// suffixed model identifiers. +func (e *GitHubCopilotExecutor) normalizeModel(model string, body []byte) []byte { + baseModel := thinking.ParseSuffix(model).ModelName + if baseModel != model { + body, _ = sjson.SetBytes(body, "model", baseModel) + } return body } diff --git a/internal/runtime/executor/github_copilot_executor_test.go b/internal/runtime/executor/github_copilot_executor_test.go new file mode 100644 index 00000000..ef077fd6 --- /dev/null +++ b/internal/runtime/executor/github_copilot_executor_test.go @@ -0,0 +1,54 @@ +package executor + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestGitHubCopilotNormalizeModel_StripsSuffix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + wantModel string + }{ + { + name: "suffix stripped", + model: "claude-opus-4.6(medium)", + wantModel: "claude-opus-4.6", + }, + { + name: "no suffix unchanged", + model: "claude-opus-4.6", + wantModel: "claude-opus-4.6", + }, + { + name: "different suffix stripped", + model: "gpt-4o(high)", + wantModel: "gpt-4o", + }, + { + name: "numeric suffix stripped", + model: "gemini-2.5-pro(8192)", + wantModel: "gemini-2.5-pro", + }, + } + + e := &GitHubCopilotExecutor{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + body := []byte(`{"model":"` + tt.model + `","messages":[]}`) + got := e.normalizeModel(tt.model, body) + + gotModel := gjson.GetBytes(got, "model").String() + if gotModel != tt.wantModel { + t.Fatalf("normalizeModel() model = %q, want %q", gotModel, tt.wantModel) + } + }) + } +} diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 2ff4000f..e12b6597 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -79,6 +79,24 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) { input: "gemini-2.5-pro(none)", want: "gemini-2.5-pro-exp-03-25(none)", }, + { + name: "github-copilot suffix preserved", + aliases: map[string][]internalconfig.OAuthModelAlias{ + "github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}}, + }, + channel: "github-copilot", + input: "opus(medium)", + want: "claude-opus-4.6(medium)", + }, + { + name: "github-copilot no suffix", + aliases: map[string][]internalconfig.OAuthModelAlias{ + "github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}}, + }, + channel: "github-copilot", + input: "opus", + want: "claude-opus-4.6", + }, { name: "kimi suffix preserved", aliases: map[string][]internalconfig.OAuthModelAlias{ @@ -174,6 +192,8 @@ func createAuthForChannel(channel string) *Auth { return &Auth{Provider: "kimi"} case "kiro": return &Auth{Provider: "kiro"} + case "github-copilot": + return &Auth{Provider: "github-copilot"} default: return &Auth{Provider: channel} } @@ -187,6 +207,22 @@ func TestOAuthModelAliasChannel_Kimi(t *testing.T) { } } +func TestOAuthModelAliasChannel_GitHubCopilot(t *testing.T) { + t.Parallel() + + if got := OAuthModelAliasChannel("github-copilot", ""); got != "github-copilot" { + t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "github-copilot") + } +} + +func TestOAuthModelAliasChannel_Kiro(t *testing.T) { + t.Parallel() + + if got := OAuthModelAliasChannel("kiro", ""); got != "kiro" { + t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "kiro") + } +} + func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) { t.Parallel()