mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-16 11:27:28 +00:00
Merge pull request #515 from JokerRun/fix/claude-tool-streaming-arguments
fix(copilot): route Claude models through native messages
This commit is contained in:
@@ -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"}},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user