diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index 56966c01..2e929df8 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -136,3 +136,96 @@ func TestRPCErrorCompatibility(t *testing.T) { } }) } + +// TestEncodeContentsEmptyPartsFilter 验证多轮历史中空 parts 内容被自动忽略而不触发编码校验失败 +func TestEncodeContentsEmptyPartsFilter(t *testing.T) { + contents := []Content{ + {Role: RoleUser, Parts: []Part{{Text: "hello"}}}, + {Role: RoleAssistant, Parts: nil}, + {Role: RoleAssistant, Parts: []Part{}}, + {Role: RoleUser, Parts: []Part{{Text: "world"}}}, + } + wire, err := encodeContents(contents) + if err != nil { + t.Fatalf("encodeContents failed: %v", err) + } + if len(wire) != 2 { + t.Fatalf("expected 2 valid wire contents, got %d: %#v", len(wire), wire) + } +} + +// TestAttachYouTubeMediaPreservesText 验证普通文本与非空白字符在附加 YouTube 媒体时不被截断为空 +func TestAttachYouTubeMediaPreservesText(t *testing.T) { + content := Content{ + Role: RoleUser, + Parts: []Part{ + {Text: " normal text with space "}, + {Text: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, + }, + } + attached := attachYouTubeMedia(content) + if len(attached.Parts) != 2 { + t.Fatalf("expected 2 parts (1 text + 1 external media), got %d: %#v", len(attached.Parts), attached.Parts) + } + if attached.Parts[0].Text != " normal text with space " { + t.Fatalf("unexpected text: %q", attached.Parts[0].Text) + } + if attached.Parts[1].ExternalMedia == nil { + t.Fatalf("expected external media part") + } +} + +// TestFunctionResultNameInference 验证在 ID 丢失、不匹配或无前置调用时安全推断函数名称与兜底 +func TestFunctionResultNameInference(t *testing.T) { + t.Run("infer from previous function call when ID mismatches", func(t *testing.T) { + contents := []Content{ + { + Role: RoleAssistant, + Parts: []Part{{ + FunctionCall: &FunctionCall{ + ID: "call_1", + Name: "query_image_presets", + Arguments: json.RawMessage(`{"category":"preset"}`), + }, + }}, + }, + { + Role: RoleTool, + Parts: []Part{{ + FunctionResult: &FunctionResult{ + ID: "call_mismatch_999", + Content: json.RawMessage(`{"result":"ok"}`), + }, + }}, + }, + } + wire, err := encodeContents(contents) + if err != nil { + t.Fatalf("encodeContents should not fail: %v", err) + } + if len(wire) != 2 { + t.Fatalf("expected 2 wire items, got %d", len(wire)) + } + }) + + t.Run("safe fallback when no preceding function call", func(t *testing.T) { + contents := []Content{ + { + Role: RoleTool, + Parts: []Part{{ + FunctionResult: &FunctionResult{ + ID: "call_isolated_123", + Content: json.RawMessage(`{"result":"ok"}`), + }, + }}, + }, + } + wire, err := encodeContents(contents) + if err != nil { + t.Fatalf("encodeContents should not fail on isolated result: %v", err) + } + if len(wire) != 1 { + t.Fatalf("expected 1 wire item, got %d", len(wire)) + } + }) +} diff --git a/internal/aistudio/request.go b/internal/aistudio/request.go index 1423c7c9..6391704c 100644 --- a/internal/aistudio/request.go +++ b/internal/aistudio/request.go @@ -97,8 +97,12 @@ func (c *Client) CountTokensForAccount(ctx context.Context, accountID string, re func encodeContents(contents []Content) ([]any, error) { wire := make([]any, 0, len(contents)) functionNames := make(map[string]string) + lastFunctionName := "" for index, content := range contents { - encoded, err := encodeContent(content, functionNames) + if len(content.Parts) == 0 { + continue + } + encoded, err := encodeContent(content, functionNames, &lastFunctionName) if err != nil { return nil, fmt.Errorf("编码 content %d: %w", index, err) } @@ -107,7 +111,7 @@ func encodeContents(contents []Content) ([]any, error) { return wire, nil } -func encodeContent(content Content, functionNames map[string]string) ([]any, error) { +func encodeContent(content Content, functionNames map[string]string, lastFunctionName *string) ([]any, error) { content = attachYouTubeMedia(content) role := "" switch content.Role { @@ -125,12 +129,25 @@ func encodeContent(content Content, functionNames map[string]string) ([]any, err } parts := make([]any, 0, len(content.Parts)) for index, part := range content.Parts { - if part.FunctionCall != nil && part.FunctionCall.ID != "" { - functionNames[part.FunctionCall.ID] = part.FunctionCall.Name + if part.FunctionCall != nil { + if part.FunctionCall.Name != "" { + *lastFunctionName = part.FunctionCall.Name + } + if part.FunctionCall.ID != "" { + functionNames[part.FunctionCall.ID] = part.FunctionCall.Name + } } if part.FunctionResult != nil && part.FunctionResult.Name == "" { part.FunctionResult = cloneFunctionResult(part.FunctionResult) - part.FunctionResult.Name = functionNames[part.FunctionResult.ID] + if name, ok := functionNames[part.FunctionResult.ID]; ok && name != "" { + part.FunctionResult.Name = name + } else if part.FunctionResult.ID != "" && !strings.HasPrefix(part.FunctionResult.ID, "call_") && !strings.HasPrefix(part.FunctionResult.ID, "tool_") { + part.FunctionResult.Name = part.FunctionResult.ID + } else if *lastFunctionName != "" { + part.FunctionResult.Name = *lastFunctionName + } else { + part.FunctionResult.Name = "unknown_function" + } } encoded, err := encodePart(part) if err != nil { @@ -229,14 +246,15 @@ func encodePart(part Part) ([]any, error) { return setPartThoughtSignature(wire, signature), nil } if part.FunctionResult != nil { - if part.FunctionResult.Name == "" { - return nil, fmt.Errorf("function result 缺少名称且无法按 call ID 解析") + name := part.FunctionResult.Name + if name == "" { + name = "unknown_function" } response, err := encodeWireStructJSON(part.FunctionResult.Content) if err != nil { return nil, fmt.Errorf("function result content: %w", err) } - result := []any{part.FunctionResult.Name, response} + result := []any{name, response} if part.FunctionResult.ID != "" { result = append(result, part.FunctionResult.ID) } diff --git a/internal/aistudio/youtube.go b/internal/aistudio/youtube.go index c59c4de4..804c981e 100644 --- a/internal/aistudio/youtube.go +++ b/internal/aistudio/youtube.go @@ -56,20 +56,23 @@ func attachYouTubeMedia(content Content) Content { parts := make([]Part, 0, len(content.Parts)+1) for _, part := range content.Parts { if part.Text != "" { - for _, raw := range youtubeURLPattern.FindAllString(part.Text, -1) { - media, ok := ExternalMediaForURL(raw) - if !ok { - continue + matches := youtubeURLPattern.FindAllString(part.Text, -1) + if len(matches) > 0 { + for _, raw := range matches { + media, ok := ExternalMediaForURL(raw) + if !ok { + continue + } + if _, duplicate := existing[media.URL]; !duplicate { + existing[media.URL] = struct{}{} + parts = append(parts, Part{ExternalMedia: media}) + } + part.Text = strings.ReplaceAll(part.Text, raw, "") } - if _, duplicate := existing[media.URL]; !duplicate { - existing[media.URL] = struct{}{} - parts = append(parts, Part{ExternalMedia: media}) + part.Text = strings.TrimSpace(part.Text) + if part.Text == "" { + continue } - part.Text = strings.ReplaceAll(part.Text, raw, "") - } - part.Text = strings.TrimSpace(part.Text) - if part.Text == "" { - continue } } parts = append(parts, part) diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 7e7d6ffa..54e389fe 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -187,6 +187,9 @@ func (request anthropicRequest) toGenerateRequest(id string) (aistudio.GenerateR if err != nil { return aistudio.GenerateRequest{}, fmt.Errorf("%s message: %w", message.Role, err) } + if len(parts) == 0 { + continue + } contents = append(contents, aistudio.Content{Role: role, Parts: parts}) } tools, err := mapAnthropicTools(request.Tools, request.ToolChoice) @@ -253,7 +256,7 @@ func anthropicRole(role string) (aistudio.Role, error) { func anthropicParts(raw json.RawMessage) ([]aistudio.Part, error) { var text string if err := json.Unmarshal(raw, &text); err == nil { - if text == "" { + if strings.TrimSpace(text) == "" { return nil, nil } return []aistudio.Part{{Text: text}}, nil @@ -295,7 +298,7 @@ func anthropicParts(raw json.RawMessage) ([]aistudio.Part, error) { } switch block.Type { case "text": - if block.Text == "" { + if strings.TrimSpace(block.Text) == "" { continue } flushPendingSignature() diff --git a/internal/api/empty_content_test.go b/internal/api/empty_content_test.go new file mode 100644 index 00000000..ec19eb43 --- /dev/null +++ b/internal/api/empty_content_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "encoding/json" + "testing" +) + +// TestOpenAIEmptyMessagesFiltered 验证 OpenAI 兼容协议中包含空字符串、null 或纯空白的消息自动跳过 +func TestOpenAIEmptyMessagesFiltered(t *testing.T) { + req := chatRequest{ + Model: "gemini-3.8-flash", + Messages: []chatMessage{ + {Role: "system", Content: json.RawMessage(`"you are an assistant"`)}, + {Role: "user", Content: json.RawMessage(`"hello"`)}, + {Role: "assistant", Content: json.RawMessage(`""`)}, + {Role: "assistant", Content: json.RawMessage(`null`)}, + {Role: "assistant", Content: json.RawMessage(`" "`)}, + {Role: "user", Content: json.RawMessage(`"how are you?"`)}, + }, + } + genReq, err := req.toGenerateRequest("test-id") + if err != nil { + t.Fatalf("toGenerateRequest failed: %v", err) + } + if len(genReq.Contents) != 2 { + t.Fatalf("expected 2 valid contents (2 user messages), got %d: %#v", len(genReq.Contents), genReq.Contents) + } + if genReq.Contents[0].Parts[0].Text != "hello" || genReq.Contents[1].Parts[0].Text != "how are you?" { + t.Fatalf("unexpected contents: %#v", genReq.Contents) + } +} + +// TestAnthropicEmptyMessagesFiltered 验证 Anthropic 协议中包含空字符串或纯空白的消息自动跳过 +func TestAnthropicEmptyMessagesFiltered(t *testing.T) { + req := anthropicRequest{ + Model: "gemini-3.8-flash", + Messages: []anthropicMessage{ + {Role: "user", Content: json.RawMessage(`"hello"`)}, + {Role: "assistant", Content: json.RawMessage(`""`)}, + {Role: "assistant", Content: json.RawMessage(`" "`)}, + {Role: "user", Content: json.RawMessage(`"world"`)}, + }, + } + genReq, err := req.toGenerateRequest("test-id") + if err != nil { + t.Fatalf("toGenerateRequest failed: %v", err) + } + if len(genReq.Contents) != 2 { + t.Fatalf("expected 2 valid contents, got %d: %#v", len(genReq.Contents), genReq.Contents) + } + if genReq.Contents[0].Parts[0].Text != "hello" || genReq.Contents[1].Parts[0].Text != "world" { + t.Fatalf("unexpected contents: %#v", genReq.Contents) + } +} + +// TestOpenAIToolMessageNameInference 验证 tool 消息缺少 name 时自动关联前置工具调用名称 +func TestOpenAIToolMessageNameInference(t *testing.T) { + req := chatRequest{ + Model: "gemini-3.8-flash", + Messages: []chatMessage{ + { + Role: "assistant", + ToolCalls: []openAIToolCall{ + { + ID: "call_abc_1", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "query_image_presets", + Arguments: `{"category":"preset"}`, + }, + }, + }, + }, + { + Role: "tool", + ToolCallID: "call_mismatched_xyz", + Content: json.RawMessage(`"ok"`), + }, + }, + } + genReq, err := req.toGenerateRequest("test-id") + if err != nil { + t.Fatalf("toGenerateRequest failed: %v", err) + } + if len(genReq.Contents) != 2 { + t.Fatalf("expected 2 contents, got %d", len(genReq.Contents)) + } + toolPart := genReq.Contents[1].Parts[0] + if toolPart.FunctionResult == nil { + t.Fatalf("expected FunctionResult part") + } + if toolPart.FunctionResult.Name != "query_image_presets" { + t.Fatalf("expected inferred name 'query_image_presets', got %q", toolPart.FunctionResult.Name) + } +} diff --git a/internal/api/openai.go b/internal/api/openai.go index 957e18a3..c6d6dbae 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -156,6 +156,8 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { func (request chatRequest) toGenerateRequest(id string) (aistudio.GenerateRequest, error) { var system []string contents := make([]aistudio.Content, 0, len(request.Messages)) + toolCallMap := make(map[string]string) + var lastToolNames []string for _, message := range request.Messages { if message.Role == "system" || message.Role == "developer" { text, err := openAITextContent(message.Content) @@ -167,10 +169,31 @@ func (request chatRequest) toGenerateRequest(id string) (aistudio.GenerateReques } continue } + if message.Role == "assistant" && len(message.ToolCalls) > 0 { + lastToolNames = nil + for _, call := range message.ToolCalls { + if call.Function.Name != "" { + lastToolNames = append(lastToolNames, call.Function.Name) + if call.ID != "" { + toolCallMap[call.ID] = call.Function.Name + } + } + } + } + if (message.Role == "tool" || message.Role == "function") && message.Name == "" { + if name, ok := toolCallMap[message.ToolCallID]; ok && name != "" { + message.Name = name + } else if len(lastToolNames) == 1 { + message.Name = lastToolNames[0] + } + } content, err := chatMessageContent(message) if err != nil { return aistudio.GenerateRequest{}, err } + if len(content.Parts) == 0 { + continue + } contents = append(contents, content) } tools, err := mapOpenAITools(request.Tools, request.ToolChoice) @@ -301,7 +324,7 @@ func openAIContentParts(raw json.RawMessage) ([]aistudio.Part, error) { } var text string if err := json.Unmarshal(raw, &text); err == nil { - if text == "" { + if strings.TrimSpace(text) == "" { return nil, nil } return []aistudio.Part{{Text: text}}, nil @@ -343,6 +366,9 @@ func openAIContentPart(raw json.RawMessage) (aistudio.Part, error) { } switch block.Type { case "text", "input_text", "output_text": + if strings.TrimSpace(block.Text) == "" { + return aistudio.Part{}, nil + } return aistudio.Part{Text: block.Text}, nil case "image_url", "input_image": url, err := imageURLString(block.ImageURL)