diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index f479f616..7bca859d 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -336,6 +336,7 @@ const defaultCopilotClaudeContextLength = 128000 // These models are available through the GitHub Copilot API at api.githubcopilot.com. func GetGitHubCopilotModels() []*ModelInfo { now := int64(1732752000) // 2024-11-27 + copilotClaudeEndpoints := []string{"/chat/completions", "/messages"} gpt4oEntries := []struct { ID string DisplayName string @@ -545,7 +546,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Haiku 4.5 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, }, { ID: "claude-opus-4.1", @@ -557,7 +558,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Opus 4.1 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 32000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, }, { ID: "claude-opus-4.5", @@ -569,7 +570,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Opus 4.5 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, }, { @@ -582,7 +583,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Opus 4.6 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, }, { @@ -595,7 +596,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Sonnet 4 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, }, { @@ -608,7 +609,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Sonnet 4.5 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, }, { @@ -621,7 +622,7 @@ func GetGitHubCopilotModels() []*ModelInfo { Description: "Anthropic Claude Sonnet 4.6 via GitHub Copilot", ContextLength: defaultCopilotClaudeContextLength, MaxCompletionTokens: 64000, - SupportedEndpoints: []string{"/chat/completions"}, + SupportedEndpoints: copilotClaudeEndpoints, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, }, { diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go index 9bf6d416..658385c3 100644 --- a/internal/registry/model_definitions_test.go +++ b/internal/registry/model_definitions_test.go @@ -27,3 +27,44 @@ func TestGitHubCopilotGeminiModelsAreChatOnly(t *testing.T) { } } } + +func TestGitHubCopilotClaudeModelsSupportMessages(t *testing.T) { + models := GetGitHubCopilotModels() + required := map[string]bool{ + "claude-haiku-4.5": false, + "claude-opus-4.1": false, + "claude-opus-4.5": false, + "claude-opus-4.6": false, + "claude-sonnet-4": false, + "claude-sonnet-4.5": false, + "claude-sonnet-4.6": false, + } + + for _, model := range models { + if _, ok := required[model.ID]; !ok { + continue + } + required[model.ID] = true + if !containsString(model.SupportedEndpoints, "/chat/completions") { + t.Fatalf("model %q supported endpoints = %v, missing /chat/completions", model.ID, model.SupportedEndpoints) + } + if !containsString(model.SupportedEndpoints, "/messages") { + t.Fatalf("model %q supported endpoints = %v, missing /messages", model.ID, model.SupportedEndpoints) + } + } + + for modelID, found := range required { + if !found { + t.Fatalf("expected GitHub Copilot model %q in definitions", modelID) + } + } +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index 4887c7c1..b9ccb391 100644 --- a/internal/runtime/executor/github_copilot_executor.go +++ b/internal/runtime/executor/github_copilot_executor.go @@ -106,6 +106,12 @@ func (e *GitHubCopilotExecutor) HttpRequest(ctx context.Context, auth *cliproxya // Execute handles non-streaming requests to GitHub Copilot. func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + if nativeExec, nativeAuth, nativeReq, ok, errGateway := e.nativeGateway(ctx, auth, req); errGateway != nil { + return resp, errGateway + } else if ok { + return nativeExec.Execute(ctx, nativeAuth, nativeReq, opts) + } + apiToken, baseURL, errToken := e.ensureAPIToken(ctx, auth) if errToken != nil { return resp, errToken @@ -239,6 +245,12 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. // ExecuteStream handles streaming requests to GitHub Copilot. func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + if nativeExec, nativeAuth, nativeReq, ok, errGateway := e.nativeGateway(ctx, auth, req); errGateway != nil { + return nil, errGateway + } else if ok { + return nativeExec.ExecuteStream(ctx, nativeAuth, nativeReq, opts) + } + apiToken, baseURL, errToken := e.ensureAPIToken(ctx, auth) if errToken != nil { return nil, errToken @@ -422,7 +434,13 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox // CountTokens estimates token count locally using tiktoken, since the GitHub // Copilot API does not expose a dedicated token counting endpoint. -func (e *GitHubCopilotExecutor) CountTokens(ctx context.Context, _ *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { +func (e *GitHubCopilotExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if nativeExec, nativeAuth, nativeReq, ok, errGateway := e.nativeGateway(ctx, auth, req); errGateway != nil { + return cliproxyexecutor.Response{}, errGateway + } else if ok { + return nativeExec.CountTokens(ctx, nativeAuth, nativeReq, opts) + } + baseModel := thinking.ParseSuffix(req.Model).ModelName from := opts.SourceFormat @@ -467,6 +485,70 @@ func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth. return auth, nil } +func (e *GitHubCopilotExecutor) nativeGateway( + ctx context.Context, + auth *cliproxyauth.Auth, + req cliproxyexecutor.Request, +) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth, cliproxyexecutor.Request, bool, error) { + if !githubCopilotUsesAnthropicGateway(req.Model) { + return nil, nil, req, false, nil + } + if auth == nil || metaStringValue(auth.Metadata, "access_token") == "" { + return nil, nil, req, false, nil + } + apiToken, baseURL, err := e.ensureAPIToken(ctx, auth) + if err != nil { + return nil, nil, req, false, err + } + nativeAuth := buildCopilotAnthropicGatewayAuth(auth, apiToken, baseURL, req.Payload) + if nativeAuth == nil { + return nil, nil, req, false, nil + } + return NewClaudeExecutor(e.cfg), nativeAuth, req, true, nil +} + +func githubCopilotUsesAnthropicGateway(model string) bool { + baseModel := strings.ToLower(thinking.ParseSuffix(model).ModelName) + return strings.HasPrefix(baseModel, "claude-") +} + +func buildCopilotAnthropicGatewayAuth(auth *cliproxyauth.Auth, apiToken, baseURL string, body []byte) *cliproxyauth.Auth { + apiToken = strings.TrimSpace(apiToken) + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if apiToken == "" || baseURL == "" { + return nil + } + + nativeAuth := auth.Clone() + if nativeAuth == nil { + nativeAuth = &cliproxyauth.Auth{} + } + nativeAuth.Provider = "claude" + if nativeAuth.Attributes == nil { + nativeAuth.Attributes = make(map[string]string) + } + nativeAuth.Attributes["api_key"] = apiToken + nativeAuth.Attributes["base_url"] = baseURL + nativeAuth.Attributes["header:Content-Type"] = "application/json" + nativeAuth.Attributes["header:Accept"] = "application/json" + nativeAuth.Attributes["header:User-Agent"] = copilotUserAgent + nativeAuth.Attributes["header:Editor-Version"] = copilotEditorVersion + nativeAuth.Attributes["header:Editor-Plugin-Version"] = copilotPluginVersion + nativeAuth.Attributes["header:Openai-Intent"] = copilotOpenAIIntent + nativeAuth.Attributes["header:Copilot-Integration-Id"] = copilotIntegrationID + nativeAuth.Attributes["header:X-Github-Api-Version"] = copilotGitHubAPIVer + nativeAuth.Attributes["header:X-Request-Id"] = uuid.NewString() + if isAgentInitiated(body) { + nativeAuth.Attributes["header:X-Initiator"] = "agent" + } else { + nativeAuth.Attributes["header:X-Initiator"] = "user" + } + if detectVisionContent(body) { + nativeAuth.Attributes["header:Copilot-Vision-Request"] = "true" + } + return nativeAuth +} + // ensureAPIToken gets or refreshes the Copilot API token. func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, string, error) { if auth == nil { diff --git a/internal/runtime/executor/github_copilot_executor_test.go b/internal/runtime/executor/github_copilot_executor_test.go index 774a3cae..9f02b1d6 100644 --- a/internal/runtime/executor/github_copilot_executor_test.go +++ b/internal/runtime/executor/github_copilot_executor_test.go @@ -2,12 +2,17 @@ package executor import ( "context" + "io" "net/http" + "net/http/httptest" "strings" "testing" + "time" 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" + 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" "github.com/tidwall/gjson" @@ -618,6 +623,144 @@ func TestCountTokens_ClaudeSourceFormatTranslates(t *testing.T) { } } +func TestGitHubCopilotExecute_ClaudeModelUsesNativeGateway(t *testing.T) { + t.Parallel() + + var gotPath string + var gotQuery string + var gotAuth string + var gotAPIVersion string + var gotEditorVersion string + var gotIntent string + var gotInitiator string + var gotBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + gotAPIVersion = r.Header.Get("X-Github-Api-Version") + gotEditorVersion = r.Header.Get("Editor-Version") + gotIntent = r.Header.Get("Openai-Intent") + gotInitiator = r.Header.Get("X-Initiator") + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-4.6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + e := NewGitHubCopilotExecutor(&config.Config{}) + e.cache["gh-access-token"] = &cachedAPIToken{ + token: "copilot-api-token", + apiEndpoint: server.URL, + expiresAt: time.Now().Add(time.Hour), + } + auth := &cliproxyauth.Auth{Metadata: map[string]any{"access_token": "gh-access-token"}} + payload := []byte(`{"model":"claude-sonnet-4.6","max_tokens":256,"messages":[{"role":"user","content":"hello"}]}`) + + resp, err := e.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4.6", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + OriginalRequest: payload, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotPath != "/v1/messages" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/messages") + } + if gotQuery != "beta=true" { + t.Fatalf("query = %q, want %q", gotQuery, "beta=true") + } + if gotAuth != "Bearer copilot-api-token" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer copilot-api-token") + } + if gotAPIVersion != copilotGitHubAPIVer { + t.Fatalf("X-Github-Api-Version = %q, want %q", gotAPIVersion, copilotGitHubAPIVer) + } + if gotEditorVersion != copilotEditorVersion { + t.Fatalf("Editor-Version = %q, want %q", gotEditorVersion, copilotEditorVersion) + } + if gotIntent != copilotOpenAIIntent { + t.Fatalf("Openai-Intent = %q, want %q", gotIntent, copilotOpenAIIntent) + } + if gotInitiator != "user" { + t.Fatalf("X-Initiator = %q, want %q", gotInitiator, "user") + } + if gjson.GetBytes(gotBody, "model").String() != "claude-sonnet-4.6" { + t.Fatalf("upstream model = %q, want %q", gjson.GetBytes(gotBody, "model").String(), "claude-sonnet-4.6") + } + if gjson.GetBytes(resp.Payload, "content.0.text").String() != "ok" { + t.Fatalf("response text = %q, want %q", gjson.GetBytes(resp.Payload, "content.0.text").String(), "ok") + } +} + +func TestGitHubCopilotExecuteStream_ClaudeModelUsesNativeGateway(t *testing.T) { + t.Parallel() + + var gotPath string + var gotInitiator string + var gotAPIVersion string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotInitiator = r.Header.Get("X-Initiator") + gotAPIVersion = r.Header.Get("X-Github-Api-Version") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4.6\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n")) + _, _ = w.Write([]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")) + _, _ = w.Write([]byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n")) + _, _ = w.Write([]byte("event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n")) + _, _ = w.Write([]byte("event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n")) + _, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")) + })) + defer server.Close() + + e := NewGitHubCopilotExecutor(&config.Config{}) + e.cache["gh-access-token"] = &cachedAPIToken{ + token: "copilot-api-token", + apiEndpoint: server.URL, + expiresAt: time.Now().Add(time.Hour), + } + auth := &cliproxyauth.Auth{Metadata: map[string]any{"access_token": "gh-access-token"}} + payload := []byte(`{"model":"claude-sonnet-4.6","stream":true,"max_tokens":256,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"path":"notes.txt"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"file contents"}]}]}`) + + result, err := e.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4.6", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + OriginalRequest: payload, + }) + if err != nil { + t.Fatalf("ExecuteStream() error: %v", err) + } + + var joined strings.Builder + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + joined.Write(chunk.Payload) + } + + if gotPath != "/v1/messages" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/messages") + } + if gotInitiator != "agent" { + t.Fatalf("X-Initiator = %q, want %q", gotInitiator, "agent") + } + if gotAPIVersion != copilotGitHubAPIVer { + t.Fatalf("X-Github-Api-Version = %q, want %q", gotAPIVersion, copilotGitHubAPIVer) + } + if !strings.Contains(joined.String(), "message_start") || !strings.Contains(joined.String(), "text_delta") { + t.Fatalf("stream = %q, want Claude SSE payload", joined.String()) + } +} + func TestCountTokens_EmptyPayload(t *testing.T) { t.Parallel() e := &GitHubCopilotExecutor{}