From ffdfad8482d5fa89d5c828a927c484a8e01f2a88 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 17 Dec 2025 13:16:07 +0800 Subject: [PATCH] Fixed: #551 fix(translator): standardize content node handling across translators for assistant and tool calls --- .../antigravity_openai_request.go | 116 ++++++++---------- .../gemini-cli_openai_request.go | 95 ++++++-------- .../chat-completions/gemini_openai_request.go | 98 +++++++-------- 3 files changed, 131 insertions(+), 178 deletions(-) diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 2a4684e2..d11afceb 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -222,62 +222,61 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) } else if role == "assistant" { + node := []byte(`{"role":"model","parts":[]}`) + p := 0 if content.Type == gjson.String { - // Assistant text -> single model content - node := []byte(`{"role":"model","parts":[{"text":""}]}`) - node, _ = sjson.SetBytes(node, "parts.0.text", content.String()) + node, _ = sjson.SetBytes(node, "parts.-1.text", content.String()) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) - } else if !content.Exists() || content.Type == gjson.Null { - // Tool calls -> single model content with functionCall parts - tcs := m.Get("tool_calls") - if tcs.IsArray() { - node := []byte(`{"role":"model","parts":[]}`) - p := 0 - fIDs := make([]string, 0) - for _, tc := range tcs.Array() { - if tc.Get("type").String() != "function" { - continue - } - fid := tc.Get("id").String() - fname := tc.Get("function.name").String() - fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) - node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) - p++ - if fid != "" { - fIDs = append(fIDs, fid) - } - } - out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) + p++ + } - // Append a single tool content combining name + response per function - toolNode := []byte(`{"role":"user","parts":[]}`) - pp := 0 - for _, fid := range fIDs { - if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) - resp := toolResponses[fid] - if resp == "" { - resp = "{}" - } - // Handle non-JSON output gracefully (matches dev branch approach) - if resp != "null" { - parsed := gjson.Parse(resp) - if parsed.Type == gjson.JSON { - toolNode, _ = sjson.SetRawBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(parsed.Raw)) - } else { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", resp) - } - } - pp++ + // Tool calls -> single model content with functionCall parts + tcs := m.Get("tool_calls") + if tcs.IsArray() { + fIDs := make([]string, 0) + for _, tc := range tcs.Array() { + if tc.Get("type").String() != "function" { + continue + } + fid := tc.Get("id").String() + fname := tc.Get("function.name").String() + fargs := tc.Get("function.arguments").String() + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) + p++ + if fid != "" { + fIDs = append(fIDs, fid) + } + } + out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) + + // Append a single tool content combining name + response per function + toolNode := []byte(`{"role":"user","parts":[]}`) + pp := 0 + for _, fid := range fIDs { + if name, ok := tcID2Name[fid]; ok { + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + resp := toolResponses[fid] + if resp == "" { + resp = "{}" } + // Handle non-JSON output gracefully (matches dev branch approach) + if resp != "null" { + parsed := gjson.Parse(resp) + if parsed.Type == gjson.JSON { + toolNode, _ = sjson.SetRawBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(parsed.Raw)) + } else { + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", resp) + } + } + pp++ } - if pp > 0 { - out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) - } + } + if pp > 0 { + out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) } } } @@ -361,18 +360,3 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } - -// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays. -func quoteIfNeeded(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "\"\"" - } - if len(s) > 0 && (s[0] == '{' || s[0] == '[') { - return s - } - // escape quotes minimally - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return "\"" + s + "\"" -} diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index dc5cf935..42365d18 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -205,52 +205,52 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo } out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) } else if role == "assistant" { + p := 0 + node := []byte(`{"role":"model","parts":[]}`) if content.Type == gjson.String { // Assistant text -> single model content - node := []byte(`{"role":"model","parts":[{"text":""}]}`) - node, _ = sjson.SetBytes(node, "parts.0.text", content.String()) + node, _ = sjson.SetBytes(node, "parts.-1.text", content.String()) out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) - } else if !content.Exists() || content.Type == gjson.Null { - // Tool calls -> single model content with functionCall parts - tcs := m.Get("tool_calls") - if tcs.IsArray() { - node := []byte(`{"role":"model","parts":[]}`) - p := 0 - fIDs := make([]string, 0) - for _, tc := range tcs.Array() { - if tc.Get("type").String() != "function" { - continue - } - fid := tc.Get("id").String() - fname := tc.Get("function.name").String() - fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) - node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) - p++ - if fid != "" { - fIDs = append(fIDs, fid) - } - } - out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) + p++ + } - // Append a single tool content combining name + response per function - toolNode := []byte(`{"role":"tool","parts":[]}`) - pp := 0 - for _, fid := range fIDs { - if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) - resp := toolResponses[fid] - if resp == "" { - resp = "{}" - } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) - pp++ + // Tool calls -> single model content with functionCall parts + tcs := m.Get("tool_calls") + if tcs.IsArray() { + fIDs := make([]string, 0) + for _, tc := range tcs.Array() { + if tc.Get("type").String() != "function" { + continue + } + fid := tc.Get("id").String() + fname := tc.Get("function.name").String() + fargs := tc.Get("function.arguments").String() + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) + p++ + if fid != "" { + fIDs = append(fIDs, fid) + } + } + out, _ = sjson.SetRawBytes(out, "request.contents.-1", node) + + // Append a single tool content combining name + response per function + toolNode := []byte(`{"role":"tool","parts":[]}`) + pp := 0 + for _, fid := range fIDs { + if name, ok := tcID2Name[fid]; ok { + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + resp := toolResponses[fid] + if resp == "" { + resp = "{}" } + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) + pp++ } - if pp > 0 { - out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) - } + } + if pp > 0 { + out, _ = sjson.SetRawBytes(out, "request.contents.-1", toolNode) } } } @@ -334,18 +334,3 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } - -// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays. -func quoteIfNeeded(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "\"\"" - } - if len(s) > 0 && (s[0] == '{' || s[0] == '[') { - return s - } - // escape quotes minimally - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return "\"" + s + "\"" -} diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 54843f0d..bc10ee34 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -207,15 +207,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } out, _ = sjson.SetRawBytes(out, "contents.-1", node) } else if role == "assistant" { + node := []byte(`{"role":"model","parts":[]}`) + p := 0 + if content.Type == gjson.String { // Assistant text -> single model content - node := []byte(`{"role":"model","parts":[{"text":""}]}`) - node, _ = sjson.SetBytes(node, "parts.0.text", content.String()) + node, _ = sjson.SetBytes(node, "parts.-1.text", content.String()) out, _ = sjson.SetRawBytes(out, "contents.-1", node) + p++ } else if content.IsArray() { // Assistant multimodal content (e.g. text + image) -> single model content with parts - node := []byte(`{"role":"model","parts":[]}`) - p := 0 for _, item := range content.Array() { switch item.Get("type").String() { case "text": @@ -237,47 +238,45 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } out, _ = sjson.SetRawBytes(out, "contents.-1", node) - } else if !content.Exists() || content.Type == gjson.Null { - // Tool calls -> single model content with functionCall parts - tcs := m.Get("tool_calls") - if tcs.IsArray() { - node := []byte(`{"role":"model","parts":[]}`) - p := 0 - fIDs := make([]string, 0) - for _, tc := range tcs.Array() { - if tc.Get("type").String() != "function" { - continue - } - fid := tc.Get("id").String() - fname := tc.Get("function.name").String() - fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) - node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature) - p++ - if fid != "" { - fIDs = append(fIDs, fid) - } - } - out, _ = sjson.SetRawBytes(out, "contents.-1", node) + } - // Append a single tool content combining name + response per function - toolNode := []byte(`{"role":"tool","parts":[]}`) - pp := 0 - for _, fid := range fIDs { - if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) - resp := toolResponses[fid] - if resp == "" { - resp = "{}" - } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) - pp++ + // Tool calls -> single model content with functionCall parts + tcs := m.Get("tool_calls") + if tcs.IsArray() { + fIDs := make([]string, 0) + for _, tc := range tcs.Array() { + if tc.Get("type").String() != "function" { + continue + } + fid := tc.Get("id").String() + fname := tc.Get("function.name").String() + fargs := tc.Get("function.arguments").String() + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature) + p++ + if fid != "" { + fIDs = append(fIDs, fid) + } + } + out, _ = sjson.SetRawBytes(out, "contents.-1", node) + + // Append a single tool content combining name + response per function + toolNode := []byte(`{"role":"tool","parts":[]}`) + pp := 0 + for _, fid := range fIDs { + if name, ok := tcID2Name[fid]; ok { + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + resp := toolResponses[fid] + if resp == "" { + resp = "{}" } + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) + pp++ } - if pp > 0 { - out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode) - } + } + if pp > 0 { + out, _ = sjson.SetRawBytes(out, "contents.-1", toolNode) } } } @@ -363,18 +362,3 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } - -// quoteIfNeeded ensures a string is valid JSON value (quotes plain text), pass-through for JSON objects/arrays. -func quoteIfNeeded(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "\"\"" - } - if len(s) > 0 && (s[0] == '{' || s[0] == '[') { - return s - } - // escape quotes minimally - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return "\"" + s + "\"" -}