Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions internal/aistudio/compatibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
})
}
34 changes: 26 additions & 8 deletions internal/aistudio/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 15 additions & 12 deletions internal/aistudio/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions internal/api/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
98 changes: 98 additions & 0 deletions internal/api/empty_content_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 27 additions & 1 deletion internal/api/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down