403 lines · 15.9 KB · Go
RawDownload
package executor import (	"net/http"	"strings"	"testing" 	"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"	sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"	"github.com/tidwall/gjson") func TestGitHubCopilotNormalizeModel_StripsSuffix(t *testing.T) {	t.Parallel() 	tests := []struct {		name      string		model     string		wantModel string	}{		{			name:      "suffix stripped",			model:     "claude-opus-4.6(medium)",			wantModel: "claude-opus-4.6",		},		{			name:      "no suffix unchanged",			model:     "claude-opus-4.6",			wantModel: "claude-opus-4.6",		},		{			name:      "different suffix stripped",			model:     "gpt-4o(high)",			wantModel: "gpt-4o",		},		{			name:      "numeric suffix stripped",			model:     "gemini-2.5-pro(8192)",			wantModel: "gemini-2.5-pro",		},	} 	e := &GitHubCopilotExecutor{} 	for _, tt := range tests {		t.Run(tt.name, func(t *testing.T) {			t.Parallel() 			body := []byte(`{"model":"` + tt.model + `","messages":[]}`)			got := e.normalizeModel(tt.model, body) 			gotModel := gjson.GetBytes(got, "model").String()			if gotModel != tt.wantModel {				t.Fatalf("normalizeModel() model = %q, want %q", gotModel, tt.wantModel)			}		})	}} func TestUseGitHubCopilotResponsesEndpoint_OpenAIResponseSource(t *testing.T) {	t.Parallel()	if !useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai-response"), "claude-3-5-sonnet") {		t.Fatal("expected openai-response source to use /responses")	}} func TestUseGitHubCopilotResponsesEndpoint_CodexModel(t *testing.T) {	t.Parallel()	if !useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "gpt-5-codex") {		t.Fatal("expected codex model to use /responses")	}} func TestUseGitHubCopilotResponsesEndpoint_RegistryResponsesOnlyModel(t *testing.T) {	t.Parallel()	if !useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "gpt-5.4") {		t.Fatal("expected responses-only registry model to use /responses")	}} func TestUseGitHubCopilotResponsesEndpoint_DynamicRegistryWinsOverStatic(t *testing.T) {	t.Parallel() 	reg := registry.GetGlobalRegistry()	clientID := "github-copilot-test-client"	reg.RegisterClient(clientID, "github-copilot", []*registry.ModelInfo{{		ID:                 "gpt-5.4",		SupportedEndpoints: []string{"/chat/completions", "/responses"},	}})	defer reg.UnregisterClient(clientID) 	if useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "gpt-5.4") {		t.Fatal("expected dynamic registry definition to take precedence over static fallback")	}} func TestUseGitHubCopilotResponsesEndpoint_DefaultChat(t *testing.T) {	t.Parallel()	if useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "claude-3-5-sonnet") {		t.Fatal("expected default openai source with non-codex model to use /chat/completions")	}} func TestNormalizeGitHubCopilotChatTools_KeepFunctionOnly(t *testing.T) {	t.Parallel()	body := []byte(`{"tools":[{"type":"function","function":{"name":"ok"}},{"type":"code_interpreter"}],"tool_choice":"auto"}`)	got := normalizeGitHubCopilotChatTools(body)	tools := gjson.GetBytes(got, "tools").Array()	if len(tools) != 1 {		t.Fatalf("tools len = %d, want 1", len(tools))	}	if tools[0].Get("type").String() != "function" {		t.Fatalf("tool type = %q, want function", tools[0].Get("type").String())	}} func TestNormalizeGitHubCopilotChatTools_InvalidToolChoiceDowngradeToAuto(t *testing.T) {	t.Parallel()	body := []byte(`{"tools":[],"tool_choice":{"type":"function","function":{"name":"x"}}}`)	got := normalizeGitHubCopilotChatTools(body)	if gjson.GetBytes(got, "tool_choice").String() != "auto" {		t.Fatalf("tool_choice = %s, want auto", gjson.GetBytes(got, "tool_choice").Raw)	}} func TestNormalizeGitHubCopilotResponsesInput_MissingInputExtractedFromSystemAndMessages(t *testing.T) {	t.Parallel()	body := []byte(`{"system":"sys text","messages":[{"role":"user","content":"user text"},{"role":"assistant","content":[{"type":"text","text":"assistant text"}]}]}`)	got := normalizeGitHubCopilotResponsesInput(body)	in := gjson.GetBytes(got, "input")	if !in.IsArray() {		t.Fatalf("input type = %v, want array", in.Type)	}	raw := in.Raw	if !strings.Contains(raw, "sys text") || !strings.Contains(raw, "user text") || !strings.Contains(raw, "assistant text") {		t.Fatalf("input = %s, want structured array with all texts", raw)	}	if gjson.GetBytes(got, "messages").Exists() {		t.Fatal("messages should be removed after conversion")	}	if gjson.GetBytes(got, "system").Exists() {		t.Fatal("system should be removed after conversion")	}} func TestNormalizeGitHubCopilotResponsesInput_NonStringInputStringified(t *testing.T) {	t.Parallel()	body := []byte(`{"input":{"foo":"bar"}}`)	got := normalizeGitHubCopilotResponsesInput(body)	in := gjson.GetBytes(got, "input")	if in.Type != gjson.String {		t.Fatalf("input type = %v, want string", in.Type)	}	if !strings.Contains(in.String(), "foo") {		t.Fatalf("input = %q, want stringified object", in.String())	}} func TestNormalizeGitHubCopilotResponsesInput_StripsServiceTier(t *testing.T) {	t.Parallel()	body := []byte(`{"input":"user text","service_tier":"default"}`)	got := normalizeGitHubCopilotResponsesInput(body) 	if gjson.GetBytes(got, "service_tier").Exists() {		t.Fatalf("service_tier should be removed, got %s", gjson.GetBytes(got, "service_tier").Raw)	}	if gjson.GetBytes(got, "input").String() != "user text" {		t.Fatalf("input = %q, want %q", gjson.GetBytes(got, "input").String(), "user text")	}} func TestNormalizeGitHubCopilotResponsesTools_FlattenFunctionTools(t *testing.T) {	t.Parallel()	body := []byte(`{"tools":[{"type":"function","function":{"name":"sum","description":"d","parameters":{"type":"object"}}},{"type":"web_search"}]}`)	got := normalizeGitHubCopilotResponsesTools(body)	tools := gjson.GetBytes(got, "tools").Array()	if len(tools) != 1 {		t.Fatalf("tools len = %d, want 1", len(tools))	}	if tools[0].Get("name").String() != "sum" {		t.Fatalf("tools[0].name = %q, want sum", tools[0].Get("name").String())	}	if !tools[0].Get("parameters").Exists() {		t.Fatal("expected parameters to be preserved")	}} func TestNormalizeGitHubCopilotResponsesTools_ClaudeFormatTools(t *testing.T) {	t.Parallel()	body := []byte(`{"tools":[{"name":"Bash","description":"Run commands","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}},{"name":"Read","description":"Read files","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`)	got := normalizeGitHubCopilotResponsesTools(body)	tools := gjson.GetBytes(got, "tools").Array()	if len(tools) != 2 {		t.Fatalf("tools len = %d, want 2", len(tools))	}	if tools[0].Get("type").String() != "function" {		t.Fatalf("tools[0].type = %q, want function", tools[0].Get("type").String())	}	if tools[0].Get("name").String() != "Bash" {		t.Fatalf("tools[0].name = %q, want Bash", tools[0].Get("name").String())	}	if tools[0].Get("description").String() != "Run commands" {		t.Fatalf("tools[0].description = %q, want 'Run commands'", tools[0].Get("description").String())	}	if !tools[0].Get("parameters").Exists() {		t.Fatal("expected parameters to be set from input_schema")	}	if tools[0].Get("parameters.properties.command").Exists() != true {		t.Fatal("expected parameters.properties.command to exist")	}	if tools[1].Get("name").String() != "Read" {		t.Fatalf("tools[1].name = %q, want Read", tools[1].Get("name").String())	}} func TestNormalizeGitHubCopilotResponsesTools_FlattenToolChoiceFunctionObject(t *testing.T) {	t.Parallel()	body := []byte(`{"tool_choice":{"type":"function","function":{"name":"sum"}}}`)	got := normalizeGitHubCopilotResponsesTools(body)	if gjson.GetBytes(got, "tool_choice.type").String() != "function" {		t.Fatalf("tool_choice.type = %q, want function", gjson.GetBytes(got, "tool_choice.type").String())	}	if gjson.GetBytes(got, "tool_choice.name").String() != "sum" {		t.Fatalf("tool_choice.name = %q, want sum", gjson.GetBytes(got, "tool_choice.name").String())	}} func TestNormalizeGitHubCopilotResponsesTools_InvalidToolChoiceDowngradeToAuto(t *testing.T) {	t.Parallel()	body := []byte(`{"tool_choice":{"type":"function"}}`)	got := normalizeGitHubCopilotResponsesTools(body)	if gjson.GetBytes(got, "tool_choice").String() != "auto" {		t.Fatalf("tool_choice = %s, want auto", gjson.GetBytes(got, "tool_choice").Raw)	}} func TestTranslateGitHubCopilotResponsesNonStreamToClaude_TextMapping(t *testing.T) {	t.Parallel()	resp := []byte(`{"id":"resp_1","model":"gpt-5-codex","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":3,"output_tokens":5}}`)	out := translateGitHubCopilotResponsesNonStreamToClaude(resp)	if gjson.Get(out, "type").String() != "message" {		t.Fatalf("type = %q, want message", gjson.Get(out, "type").String())	}	if gjson.Get(out, "content.0.type").String() != "text" {		t.Fatalf("content.0.type = %q, want text", gjson.Get(out, "content.0.type").String())	}	if gjson.Get(out, "content.0.text").String() != "hello" {		t.Fatalf("content.0.text = %q, want hello", gjson.Get(out, "content.0.text").String())	}} func TestTranslateGitHubCopilotResponsesNonStreamToClaude_ToolUseMapping(t *testing.T) {	t.Parallel()	resp := []byte(`{"id":"resp_2","model":"gpt-5-codex","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"sum","arguments":"{\"a\":1}"}],"usage":{"input_tokens":1,"output_tokens":2}}`)	out := translateGitHubCopilotResponsesNonStreamToClaude(resp)	if gjson.Get(out, "content.0.type").String() != "tool_use" {		t.Fatalf("content.0.type = %q, want tool_use", gjson.Get(out, "content.0.type").String())	}	if gjson.Get(out, "content.0.name").String() != "sum" {		t.Fatalf("content.0.name = %q, want sum", gjson.Get(out, "content.0.name").String())	}	if gjson.Get(out, "stop_reason").String() != "tool_use" {		t.Fatalf("stop_reason = %q, want tool_use", gjson.Get(out, "stop_reason").String())	}} func TestTranslateGitHubCopilotResponsesStreamToClaude_TextLifecycle(t *testing.T) {	t.Parallel()	var param any 	created := translateGitHubCopilotResponsesStreamToClaude([]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5-codex"}}`), &param)	if len(created) == 0 || !strings.Contains(created[0], "message_start") {		t.Fatalf("created events = %#v, want message_start", created)	} 	delta := translateGitHubCopilotResponsesStreamToClaude([]byte(`data: {"type":"response.output_text.delta","delta":"he"}`), &param)	joinedDelta := strings.Join(delta, "")	if !strings.Contains(joinedDelta, "content_block_start") || !strings.Contains(joinedDelta, "text_delta") {		t.Fatalf("delta events = %#v, want content_block_start + text_delta", delta)	} 	completed := translateGitHubCopilotResponsesStreamToClaude([]byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":9}}}`), &param)	joinedCompleted := strings.Join(completed, "")	if !strings.Contains(joinedCompleted, "message_delta") || !strings.Contains(joinedCompleted, "message_stop") {		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_UserWhenLastRoleIsUser(t *testing.T) {	t.Parallel()	e := &GitHubCopilotExecutor{}	req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)	// Last role governs the initiator decision.	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 != "user" {		t.Fatalf("X-Initiator = %q, want user (last role is user)", 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)	}} func TestApplyHeaders_XInitiator_InputArrayLastAssistantMessage(t *testing.T) {	t.Parallel()	e := &GitHubCopilotExecutor{}	req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)	body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}]}`)	e.applyHeaders(req, "token", body)	if got := req.Header.Get("X-Initiator"); got != "agent" {		t.Fatalf("X-Initiator = %q, want agent (last role is assistant)", got)	}} func TestApplyHeaders_XInitiator_InputArrayLastUserMessage(t *testing.T) {	t.Parallel()	e := &GitHubCopilotExecutor{}	req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)	body := []byte(`{"input":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I can help"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"Do X"}]}]}`)	e.applyHeaders(req, "token", body)	if got := req.Header.Get("X-Initiator"); got != "user" {		t.Fatalf("X-Initiator = %q, want user (last role is user)", got)	}} func TestApplyHeaders_XInitiator_InputArrayLastFunctionCallOutput(t *testing.T) {	t.Parallel()	e := &GitHubCopilotExecutor{}	req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)	body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Use tool"}]},{"type":"function_call","call_id":"c1","name":"Read","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"ok"}]}`)	e.applyHeaders(req, "token", body)	if got := req.Header.Get("X-Initiator"); got != "agent" {		t.Fatalf("X-Initiator = %q, want agent (last item maps to tool role)", 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")	}}