diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index f585353c..cf6b3f09 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -258,6 +258,19 @@ func GetGitHubCopilotModels() []*ModelInfo { SupportedEndpoints: []string{"/responses"}, Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}}, }, + { + ID: "gpt-5.3-codex", + Object: "model", + Created: now, + OwnedBy: "github-copilot", + Type: "github-copilot", + DisplayName: "GPT-5.3 Codex", + Description: "OpenAI GPT-5.3 Codex via GitHub Copilot", + ContextLength: 200000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/responses"}, + Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}}, + }, { ID: "claude-haiku-4.5", Object: "model", @@ -330,6 +343,18 @@ func GetGitHubCopilotModels() []*ModelInfo { MaxCompletionTokens: 64000, SupportedEndpoints: []string{"/chat/completions"}, }, + { + ID: "claude-sonnet-4.6", + Object: "model", + Created: now, + OwnedBy: "github-copilot", + Type: "github-copilot", + DisplayName: "Claude Sonnet 4.6", + Description: "Anthropic Claude Sonnet 4.6 via GitHub Copilot", + ContextLength: 200000, + MaxCompletionTokens: 64000, + SupportedEndpoints: []string{"/chat/completions"}, + }, { ID: "gemini-2.5-pro", Object: "model", diff --git a/internal/registry/model_definitions_static_data.go b/internal/registry/model_definitions_static_data.go index d810bcde..5625efea 100644 --- a/internal/registry/model_definitions_static_data.go +++ b/internal/registry/model_definitions_static_data.go @@ -43,11 +43,11 @@ func GetClaudeModels() []*ModelInfo { { ID: "claude-sonnet-4-6", Object: "model", - Created: 1739836800, // 2025-02-18 + Created: 1771286400, // 2026-02-17 OwnedBy: "anthropic", Type: "claude", DisplayName: "Claude 4.6 Sonnet", - Description: "High-performance model balancing intelligence and speed", + Description: "Best combination of speed and intelligence", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false}, @@ -905,11 +905,13 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}}, "gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}}, "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}}, - "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-sonnet-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-sonnet-4-5": {MaxCompletionTokens: 64000}, + "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, + "claude-sonnet-4-6": {MaxCompletionTokens: 64000}, + "claude-sonnet-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "gpt-oss-120b-medium": {}, "tab_flash_lite_preview": {}, } diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index 173c4752..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-edits" + 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") + } +}