Merge pull request #515 from JokerRun/fix/claude-tool-streaming-arguments

fix(copilot): route Claude models through native messages
This commit is contained in:
Luis Pater
2026-04-16 03:20:21 +08:00
committed by GitHub
4 changed files with 275 additions and 8 deletions

View File

@@ -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"}},
},
{

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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{}