From f9f3c5fb4221fe5eb94860f7b63858da2300f6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 3 Apr 2026 17:33:53 +0800 Subject: [PATCH] refactor(task-6): migrate adapters to pkg/adapter - Migrate OpenAI adapter - Migrate Anthropic adapter - Update import paths --- pkg/adapter/anthropic/anthropic.go | 174 +++++++++++++++++ pkg/adapter/anthropic/anthropic_test.go | 109 +++++++++++ pkg/adapter/openai/openai.go | 243 ++++++++++++++++++++++++ pkg/adapter/openai/openai_test.go | 80 ++++++++ 4 files changed, 606 insertions(+) create mode 100644 pkg/adapter/anthropic/anthropic.go create mode 100644 pkg/adapter/anthropic/anthropic_test.go create mode 100644 pkg/adapter/openai/openai.go create mode 100644 pkg/adapter/openai/openai_test.go diff --git a/pkg/adapter/anthropic/anthropic.go b/pkg/adapter/anthropic/anthropic.go new file mode 100644 index 0000000..fe3f64e --- /dev/null +++ b/pkg/adapter/anthropic/anthropic.go @@ -0,0 +1,174 @@ +package anthropic + +import ( + "cursor-api-proxy/pkg/adapter/openai" + "encoding/json" + "fmt" + "strings" +) + +type MessageParam struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +type MessagesRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []MessageParam `json:"messages"` + System interface{} `json:"system"` + Stream bool `json:"stream"` + Tools []interface{} `json:"tools"` +} + +func systemToText(system interface{}) string { + if system == nil { + return "" + } + switch v := system.(type) { + case string: + return strings.TrimSpace(v) + case []interface{}: + var parts []string + for _, p := range v { + if m, ok := p.(map[string]interface{}); ok { + if m["type"] == "text" { + if t, ok := m["text"].(string); ok { + parts = append(parts, t) + } + } + } + } + return strings.Join(parts, "\n") + } + return "" +} + +func anthropicBlockToText(p interface{}) string { + if p == nil { + return "" + } + switch v := p.(type) { + case string: + return v + case map[string]interface{}: + typ, _ := v["type"].(string) + switch typ { + case "text": + if t, ok := v["text"].(string); ok { + return t + } + case "image": + if src, ok := v["source"].(map[string]interface{}); ok { + srcType, _ := src["type"].(string) + switch srcType { + case "base64": + mt, _ := src["media_type"].(string) + if mt == "" { + mt = "image" + } + return "[Image: base64 " + mt + "]" + case "url": + url, _ := src["url"].(string) + return "[Image: " + url + "]" + } + } + return "[Image]" + case "document": + title, _ := v["title"].(string) + if title == "" { + if src, ok := v["source"].(map[string]interface{}); ok { + title, _ = src["url"].(string) + } + } + if title != "" { + return "[Document: " + title + "]" + } + return "[Document]" + case "tool_use": + name, _ := v["name"].(string) + id, _ := v["id"].(string) + input := v["input"] + inputJSON, _ := json.Marshal(input) + if inputJSON == nil { + inputJSON = []byte("{}") + } + tag := fmt.Sprintf("\n{\"name\": \"%s\", \"arguments\": %s}\n", name, string(inputJSON)) + if id != "" { + tag = fmt.Sprintf("[tool_use_id=%s] ", id) + tag + } + return tag + case "tool_result": + toolUseID, _ := v["tool_use_id"].(string) + content := v["content"] + var contentText string + switch c := content.(type) { + case string: + contentText = c + case []interface{}: + var parts []string + for _, block := range c { + if bm, ok := block.(map[string]interface{}); ok { + if bm["type"] == "text" { + if t, ok := bm["text"].(string); ok { + parts = append(parts, t) + } + } + } + } + contentText = strings.Join(parts, "\n") + } + label := "Tool result" + if toolUseID != "" { + label += " [id=" + toolUseID + "]" + } + return label + ": " + contentText + } + } + return "" +} + +func anthropicContentToText(content interface{}) string { + switch v := content.(type) { + case string: + return v + case []interface{}: + var parts []string + for _, p := range v { + if t := anthropicBlockToText(p); t != "" { + parts = append(parts, t) + } + } + return strings.Join(parts, " ") + } + return "" +} + +func BuildPromptFromAnthropicMessages(messages []MessageParam, system interface{}) string { + var oaiMessages []interface{} + + systemText := systemToText(system) + if systemText != "" { + oaiMessages = append(oaiMessages, map[string]interface{}{ + "role": "system", + "content": systemText, + }) + } + + for _, m := range messages { + text := anthropicContentToText(m.Content) + if text == "" { + continue + } + role := m.Role + if role != "user" && role != "assistant" { + role = "user" + } + oaiMessages = append(oaiMessages, map[string]interface{}{ + "role": role, + "content": text, + }) + } + + return openai.BuildPromptFromMessages(oaiMessages) +} diff --git a/pkg/adapter/anthropic/anthropic_test.go b/pkg/adapter/anthropic/anthropic_test.go new file mode 100644 index 0000000..1945a9d --- /dev/null +++ b/pkg/adapter/anthropic/anthropic_test.go @@ -0,0 +1,109 @@ +package anthropic_test + +import ( + "cursor-api-proxy/pkg/adapter/anthropic" + "strings" + "testing" +) + +func TestBuildPromptFromAnthropicMessages_Simple(t *testing.T) { + messages := []anthropic.MessageParam{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there"}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil) + if !strings.Contains(prompt, "Hello") { + t.Errorf("prompt missing user message: %q", prompt) + } + if !strings.Contains(prompt, "Hi there") { + t.Errorf("prompt missing assistant message: %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_WithSystem(t *testing.T) { + messages := []anthropic.MessageParam{ + {Role: "user", Content: "ping"}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, "You are a helpful bot.") + if !strings.Contains(prompt, "You are a helpful bot.") { + t.Errorf("prompt missing system: %q", prompt) + } + if !strings.Contains(prompt, "ping") { + t.Errorf("prompt missing user: %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_SystemArray(t *testing.T) { + system := []interface{}{ + map[string]interface{}{"type": "text", "text": "Part A"}, + map[string]interface{}{"type": "text", "text": "Part B"}, + } + messages := []anthropic.MessageParam{ + {Role: "user", Content: "test"}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, system) + if !strings.Contains(prompt, "Part A") { + t.Errorf("prompt missing Part A: %q", prompt) + } + if !strings.Contains(prompt, "Part B") { + t.Errorf("prompt missing Part B: %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_ContentBlocks(t *testing.T) { + content := []interface{}{ + map[string]interface{}{"type": "text", "text": "block one"}, + map[string]interface{}{"type": "text", "text": "block two"}, + } + messages := []anthropic.MessageParam{ + {Role: "user", Content: content}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil) + if !strings.Contains(prompt, "block one") { + t.Errorf("prompt missing 'block one': %q", prompt) + } + if !strings.Contains(prompt, "block two") { + t.Errorf("prompt missing 'block two': %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_ImageBlock(t *testing.T) { + content := []interface{}{ + map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": "image/png", + "data": "abc123", + }, + }, + } + messages := []anthropic.MessageParam{ + {Role: "user", Content: content}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil) + if !strings.Contains(prompt, "[Image") { + t.Errorf("prompt missing [Image]: %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_EmptyContentSkipped(t *testing.T) { + messages := []anthropic.MessageParam{ + {Role: "user", Content: ""}, + {Role: "assistant", Content: "response"}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil) + if !strings.Contains(prompt, "response") { + t.Errorf("prompt missing 'response': %q", prompt) + } +} + +func TestBuildPromptFromAnthropicMessages_UnknownRoleBecomesUser(t *testing.T) { + messages := []anthropic.MessageParam{ + {Role: "system", Content: "system-as-user"}, + } + prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil) + if !strings.Contains(prompt, "system-as-user") { + t.Errorf("prompt missing 'system-as-user': %q", prompt) + } +} diff --git a/pkg/adapter/openai/openai.go b/pkg/adapter/openai/openai.go new file mode 100644 index 0000000..86919a6 --- /dev/null +++ b/pkg/adapter/openai/openai.go @@ -0,0 +1,243 @@ +package openai + +import ( + "encoding/json" + "fmt" + "strings" +) + +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []interface{} `json:"messages"` + Stream bool `json:"stream"` + Tools []interface{} `json:"tools"` + ToolChoice interface{} `json:"tool_choice"` + Functions []interface{} `json:"functions"` + FunctionCall interface{} `json:"function_call"` +} + +func NormalizeModelID(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + parts := strings.Split(trimmed, "/") + last := parts[len(parts)-1] + if last == "" { + return "" + } + return last +} + +func imageURLToText(imageURL interface{}) string { + if imageURL == nil { + return "[Image]" + } + var url string + switch v := imageURL.(type) { + case string: + url = v + case map[string]interface{}: + if u, ok := v["url"].(string); ok { + url = u + } + } + if url == "" { + return "[Image]" + } + if strings.HasPrefix(url, "data:") { + end := strings.Index(url, ";") + mime := "image" + if end > 5 { + mime = url[5:end] + } + return "[Image: base64 " + mime + "]" + } + return "[Image: " + url + "]" +} + +func MessageContentToText(content interface{}) string { + if content == nil { + return "" + } + switch v := content.(type) { + case string: + return v + case []interface{}: + var parts []string + for _, p := range v { + if p == nil { + continue + } + switch part := p.(type) { + case string: + parts = append(parts, part) + case map[string]interface{}: + typ, _ := part["type"].(string) + switch typ { + case "text": + if t, ok := part["text"].(string); ok { + parts = append(parts, t) + } + case "image_url": + parts = append(parts, imageURLToText(part["image_url"])) + case "image": + src := part["source"] + if src == nil { + src = part["url"] + } + parts = append(parts, imageURLToText(src)) + } + } + } + return strings.Join(parts, " ") + } + return "" +} + +func ToolsToSystemText(tools []interface{}, functions []interface{}) string { + var defs []interface{} + + for _, t := range tools { + if m, ok := t.(map[string]interface{}); ok { + if m["type"] == "function" { + if fn := m["function"]; fn != nil { + defs = append(defs, fn) + } + } else { + defs = append(defs, t) + } + } + } + defs = append(defs, functions...) + + if len(defs) == 0 { + return "" + } + + var lines []string + lines = append(lines, "Available tools (respond with a JSON object to call one):", "") + + for _, raw := range defs { + fn, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name, _ := fn["name"].(string) + desc, _ := fn["description"].(string) + params := "{}" + if p := fn["parameters"]; p != nil { + if b, err := json.MarshalIndent(p, "", " "); err == nil { + params = string(b) + } + } else if p := fn["input_schema"]; p != nil { + if b, err := json.MarshalIndent(p, "", " "); err == nil { + params = string(b) + } + } + lines = append(lines, "Function: "+name+"\nDescription: "+desc+"\nParameters: "+params) + } + + lines = append(lines, "", + "When you want to call a tool, use this EXACT format:", + "", + "", + `{"name": "function_name", "arguments": {"param1": "value1"}}`, + "", + "", + "Rules:", + "- Write your reasoning BEFORE the tool call", + "- You may make multiple tool calls by using multiple blocks", + "- STOP writing after the last tag", + "- If no tool is needed, respond normally without tags", + ) + + return strings.Join(lines, "\n") +} + +type SimpleMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func BuildPromptFromMessages(messages []interface{}) string { + var systemParts []string + var convo []string + + for _, raw := range messages { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + role, _ := m["role"].(string) + text := MessageContentToText(m["content"]) + + switch role { + case "system", "developer": + if text != "" { + systemParts = append(systemParts, text) + } + case "user": + if text != "" { + convo = append(convo, "User: "+text) + } + case "assistant": + toolCalls, _ := m["tool_calls"].([]interface{}) + if len(toolCalls) > 0 { + var parts []string + if text != "" { + parts = append(parts, text) + } + for _, tc := range toolCalls { + tcMap, ok := tc.(map[string]interface{}) + if !ok { + continue + } + fn, _ := tcMap["function"].(map[string]interface{}) + if fn == nil { + continue + } + name, _ := fn["name"].(string) + args, _ := fn["arguments"].(string) + if args == "" { + args = "{}" + } + parts = append(parts, fmt.Sprintf("\n{\"name\": \"%s\", \"arguments\": %s}\n", name, args)) + } + if len(parts) > 0 { + convo = append(convo, "Assistant: "+strings.Join(parts, "\n")) + } + } else if text != "" { + convo = append(convo, "Assistant: "+text) + } + case "tool", "function": + name, _ := m["name"].(string) + toolCallID, _ := m["tool_call_id"].(string) + label := "Tool result" + if name != "" { + label = "Tool result (" + name + ")" + } + if toolCallID != "" { + label += " [id=" + toolCallID + "]" + } + if text != "" { + convo = append(convo, label+": "+text) + } + } + } + + system := "" + if len(systemParts) > 0 { + system = "System:\n" + strings.Join(systemParts, "\n\n") + "\n\n" + } + transcript := strings.Join(convo, "\n\n") + return system + transcript + "\n\nAssistant:" +} + +func BuildPromptFromSimpleMessages(messages []SimpleMessage) string { + ifaces := make([]interface{}, len(messages)) + for i, m := range messages { + ifaces[i] = map[string]interface{}{"role": m.Role, "content": m.Content} + } + return BuildPromptFromMessages(ifaces) +} diff --git a/pkg/adapter/openai/openai_test.go b/pkg/adapter/openai/openai_test.go new file mode 100644 index 0000000..04ede1b --- /dev/null +++ b/pkg/adapter/openai/openai_test.go @@ -0,0 +1,80 @@ +package openai + +import "testing" + +func TestNormalizeModelID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"gpt-4", "gpt-4"}, + {"openai/gpt-4", "gpt-4"}, + {"anthropic/claude-3", "claude-3"}, + {"", ""}, + {" ", ""}, + {"a/b/c", "c"}, + } + for _, tc := range tests { + got := NormalizeModelID(tc.input) + if got != tc.want { + t.Errorf("NormalizeModelID(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestBuildPromptFromMessages(t *testing.T) { + messages := []interface{}{ + map[string]interface{}{"role": "system", "content": "You are helpful."}, + map[string]interface{}{"role": "user", "content": "Hello"}, + map[string]interface{}{"role": "assistant", "content": "Hi there"}, + } + got := BuildPromptFromMessages(messages) + if got == "" { + t.Fatal("expected non-empty prompt") + } + containsSystem := false + containsUser := false + containsAssistant := false + for i := 0; i < len(got)-10; i++ { + if got[i:i+6] == "System" { + containsSystem = true + } + if got[i:i+4] == "User" { + containsUser = true + } + if got[i:i+9] == "Assistant" { + containsAssistant = true + } + } + if !containsSystem || !containsUser || !containsAssistant { + t.Errorf("prompt missing sections: system=%v user=%v assistant=%v\n%s", + containsSystem, containsUser, containsAssistant, got) + } +} + +func TestToolsToSystemText(t *testing.T) { + tools := []interface{}{ + map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "get_weather", + "description": "Get weather", + "parameters": map[string]interface{}{"type": "object"}, + }, + }, + } + got := ToolsToSystemText(tools, nil) + if got == "" { + t.Fatal("expected non-empty tools text") + } + if len(got) < 10 { + t.Errorf("tools text too short: %q", got) + } +} + +func TestToolsToSystemTextEmpty(t *testing.T) { + got := ToolsToSystemText(nil, nil) + if got != "" { + t.Errorf("expected empty string for no tools, got %q", got) + } +}