diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index 95f8ef17..0189ffc8 100644 --- a/internal/runtime/executor/github_copilot_executor.go +++ b/internal/runtime/executor/github_copilot_executor.go @@ -35,11 +35,12 @@ const ( maxScannerBufferSize = 20_971_520 // Copilot API header values. - copilotUserAgent = "GitHubCopilotChat/0.35.0" - copilotEditorVersion = "vscode/1.107.0" - copilotPluginVersion = "copilot-chat/0.35.0" - copilotIntegrationID = "vscode-chat" - copilotOpenAIIntent = "conversation-panel" + copilotUserAgent = "GitHubCopilotChat/0.35.0" + copilotEditorVersion = "vscode/1.107.0" + copilotPluginVersion = "copilot-chat/0.35.0" + copilotIntegrationID = "vscode-chat" + copilotOpenAIIntent = "conversation-panel" + copilotGitHubAPIVer = "2025-04-01" ) // GitHubCopilotExecutor handles requests to the GitHub Copilot API. @@ -51,8 +52,9 @@ type GitHubCopilotExecutor struct { // cachedAPIToken stores a cached Copilot API token with its expiry. type cachedAPIToken struct { - token string - expiresAt time.Time + token string + apiEndpoint string + expiresAt time.Time } // NewGitHubCopilotExecutor constructs a new executor instance. @@ -75,7 +77,7 @@ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxy if ctx == nil { ctx = context.Background() } - apiToken, errToken := e.ensureAPIToken(ctx, auth) + apiToken, _, errToken := e.ensureAPIToken(ctx, auth) if errToken != nil { return errToken } @@ -101,7 +103,7 @@ 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) { - apiToken, errToken := e.ensureAPIToken(ctx, auth) + apiToken, baseURL, errToken := e.ensureAPIToken(ctx, auth) if errToken != nil { return resp, errToken } @@ -124,6 +126,9 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. body = e.normalizeModel(req.Model, body) body = flattenAssistantContent(body) + // Detect vision content before input normalization removes messages + hasVision := detectVisionContent(body) + thinkingProvider := "openai" if useResponses { thinkingProvider = "codex" @@ -147,7 +152,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. if useResponses { path = githubCopilotResponsesPath } - url := githubCopilotBaseURL + path + url := baseURL + path httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return resp, err @@ -155,7 +160,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. e.applyHeaders(httpReq, apiToken, body) // Add Copilot-Vision-Request header if the request contains vision content - if detectVisionContent(body) { + if hasVision { httpReq.Header.Set("Copilot-Vision-Request", "true") } @@ -228,7 +233,7 @@ 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) (stream <-chan cliproxyexecutor.StreamChunk, err error) { - apiToken, errToken := e.ensureAPIToken(ctx, auth) + apiToken, baseURL, errToken := e.ensureAPIToken(ctx, auth) if errToken != nil { return nil, errToken } @@ -251,6 +256,9 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox body = e.normalizeModel(req.Model, body) body = flattenAssistantContent(body) + // Detect vision content before input normalization removes messages + hasVision := detectVisionContent(body) + thinkingProvider := "openai" if useResponses { thinkingProvider = "codex" @@ -278,7 +286,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox if useResponses { path = githubCopilotResponsesPath } - url := githubCopilotBaseURL + path + url := baseURL + path httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err @@ -286,7 +294,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox e.applyHeaders(httpReq, apiToken, body) // Add Copilot-Vision-Request header if the request contains vision content - if detectVisionContent(body) { + if hasVision { httpReq.Header.Set("Copilot-Vision-Request", "true") } @@ -418,22 +426,22 @@ func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth. } // ensureAPIToken gets or refreshes the Copilot API token. -func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) { +func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, string, error) { if auth == nil { - return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"} + return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"} } // Get the GitHub access token accessToken := metaStringValue(auth.Metadata, "access_token") if accessToken == "" { - return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"} + return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"} } // Check for cached API token using thread-safe access e.mu.RLock() if cached, ok := e.cache[accessToken]; ok && cached.expiresAt.After(time.Now().Add(tokenExpiryBuffer)) { e.mu.RUnlock() - return cached.token, nil + return cached.token, cached.apiEndpoint, nil } e.mu.RUnlock() @@ -441,7 +449,13 @@ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *clipro copilotAuth := copilotauth.NewCopilotAuth(e.cfg) apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken) if err != nil { - return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)} + return "", "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)} + } + + // Use endpoint from token response, fall back to default + apiEndpoint := githubCopilotBaseURL + if apiToken.Endpoints.API != "" { + apiEndpoint = strings.TrimRight(apiToken.Endpoints.API, "/") } // Cache the token with thread-safe access @@ -451,12 +465,13 @@ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *clipro } e.mu.Lock() e.cache[accessToken] = &cachedAPIToken{ - token: apiToken.Token, - expiresAt: expiresAt, + token: apiToken.Token, + apiEndpoint: apiEndpoint, + expiresAt: expiresAt, } e.mu.Unlock() - return apiToken.Token, nil + return apiToken.Token, apiEndpoint, nil } // applyHeaders sets the required headers for GitHub Copilot API requests. @@ -469,16 +484,17 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, b r.Header.Set("Editor-Plugin-Version", copilotPluginVersion) r.Header.Set("Openai-Intent", copilotOpenAIIntent) r.Header.Set("Copilot-Integration-Id", copilotIntegrationID) + r.Header.Set("X-Github-Api-Version", copilotGitHubAPIVer) r.Header.Set("X-Request-Id", uuid.NewString()) initiator := "user" if len(body) > 0 { if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() { - arr := messages.Array() - if len(arr) > 0 { - lastRole := arr[len(arr)-1].Get("role").String() - if lastRole != "" && lastRole != "user" { + for _, msg := range messages.Array() { + role := msg.Get("role").String() + if role == "assistant" || role == "tool" { initiator = "agent" + break } } } diff --git a/internal/runtime/executor/github_copilot_executor_test.go b/internal/runtime/executor/github_copilot_executor_test.go index 41877414..39868ef7 100644 --- a/internal/runtime/executor/github_copilot_executor_test.go +++ b/internal/runtime/executor/github_copilot_executor_test.go @@ -1,6 +1,7 @@ package executor import ( + "net/http" "strings" "testing" @@ -247,3 +248,86 @@ func TestTranslateGitHubCopilotResponsesStreamToClaude_TextLifecycle(t *testing. t.Fatalf("completed events = %#v, want message_delta + message_stop", completed) } } + +// --- Tests for X-Initiator detection logic (Problem L) --- + +func TestApplyHeaders_XInitiator_UserOnly(t *testing.T) { + t.Parallel() + e := &GitHubCopilotExecutor{} + req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + body := []byte(`{"messages":[{"role":"system","content":"sys"},{"role":"user","content":"hello"}]}`) + e.applyHeaders(req, "token", body) + if got := req.Header.Get("X-Initiator"); got != "user" { + t.Fatalf("X-Initiator = %q, want user", got) + } +} + +func TestApplyHeaders_XInitiator_AgentWithAssistantAndUserToolResult(t *testing.T) { + t.Parallel() + e := &GitHubCopilotExecutor{} + req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + // Claude Code typical flow: last message is user (tool result), but has assistant in history + body := []byte(`{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"I will read the file"},{"role":"user","content":"tool result here"}]}`) + e.applyHeaders(req, "token", body) + if got := req.Header.Get("X-Initiator"); got != "agent" { + t.Fatalf("X-Initiator = %q, want agent (assistant exists in messages)", got) + } +} + +func TestApplyHeaders_XInitiator_AgentWithToolRole(t *testing.T) { + t.Parallel() + e := &GitHubCopilotExecutor{} + req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + body := []byte(`{"messages":[{"role":"user","content":"hello"},{"role":"tool","content":"result"}]}`) + e.applyHeaders(req, "token", body) + if got := req.Header.Get("X-Initiator"); got != "agent" { + t.Fatalf("X-Initiator = %q, want agent (tool role exists)", got) + } +} + +// --- Tests for x-github-api-version header (Problem M) --- + +func TestApplyHeaders_GitHubAPIVersion(t *testing.T) { + t.Parallel() + e := &GitHubCopilotExecutor{} + req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + e.applyHeaders(req, "token", nil) + if got := req.Header.Get("X-Github-Api-Version"); got != "2025-04-01" { + t.Fatalf("X-Github-Api-Version = %q, want 2025-04-01", got) + } +} + +// --- Tests for vision detection (Problem P) --- + +func TestDetectVisionContent_WithImageURL(t *testing.T) { + t.Parallel() + body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`) + if !detectVisionContent(body) { + t.Fatal("expected vision content to be detected") + } +} + +func TestDetectVisionContent_WithImageType(t *testing.T) { + t.Parallel() + body := []byte(`{"messages":[{"role":"user","content":[{"type":"image","source":{"data":"abc","media_type":"image/png"}}]}]}`) + if !detectVisionContent(body) { + t.Fatal("expected image type to be detected") + } +} + +func TestDetectVisionContent_NoVision(t *testing.T) { + t.Parallel() + body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + if detectVisionContent(body) { + t.Fatal("expected no vision content") + } +} + +func TestDetectVisionContent_NoMessages(t *testing.T) { + t.Parallel() + // After Responses API normalization, messages is removed — detection should return false + body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`) + if detectVisionContent(body) { + t.Fatal("expected no vision content when messages field is absent") + } +}