feat(translator): normalize and extract OpenAI file content with MIME type

- Added `NormalizeOpenAIFileData` utility to parse and validate file content, ensuring proper MIME type handling and base64 extraction.
- Refactored translation logic to use normalized file data for Gemini, Antigravity, and Interactions processors.
- Introduced comprehensive unit tests for both valid and invalid file data scenarios across all translators.

Fixed: #4416
This commit is contained in:
Luis Pater
2026-07-19 18:29:05 +08:00
parent 07f8354912
commit e47ffda75b
12 changed files with 249 additions and 35 deletions
@@ -0,0 +1,20 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToAntigravityNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertInteractionsRequestToAntigravity("gemini-3.5-flash", input, false)
inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
@@ -5,7 +5,6 @@ import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
@@ -459,12 +458,8 @@ func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result,
case "file":
filename := content.Get("file.filename").String()
fileData := content.Get("file.file_data").String()
ext := ""
if sp := strings.Split(filename, "."); len(sp) > 1 {
ext = sp[len(sp)-1]
}
if mimeType, ok := misc.MimeTypes[ext]; ok && fileData != "" {
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, fileData)))
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
return nil
@@ -0,0 +1,20 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToAntigravityNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToAntigravity("gemini-2.5-pro", input, false)
inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
@@ -5,7 +5,6 @@ package chat_completions
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
@@ -189,14 +188,10 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
case "file":
filename := item.Get("file.filename").String()
fileData := item.Get("file.file_data").String()
ext := ""
if pieces := strings.Split(filename, "."); len(pieces) > 1 {
ext = pieces[len(pieces)-1]
}
if mimeType, ok := misc.MimeTypes[ext]; ok {
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, fileData, false))
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false))
} else {
log.Warnf("Unknown file name extension '%s' in user message, skip", ext)
log.Warn("Invalid file data or unknown file name extension in user message, skip")
}
case "input_audio":
audioData := item.Get("input_audio.data").String()
+43
View File
@@ -0,0 +1,43 @@
package common
import (
"path/filepath"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
)
// NormalizeOpenAIFileData returns the MIME type and raw base64 payload for OpenAI file content.
func NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData string) (mimeType, data string, ok bool) {
if fileData == "" {
return "", "", false
}
if fallbackMIMEType == "" {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
fallbackMIMEType = misc.MimeTypes[ext]
}
const dataURLPrefix = "data:"
if len(fileData) < len(dataURLPrefix) || !strings.EqualFold(fileData[:len(dataURLPrefix)], dataURLPrefix) {
if fallbackMIMEType == "" {
return "", "", false
}
return fallbackMIMEType, fileData, true
}
metadata, payload, found := strings.Cut(fileData[len(dataURLPrefix):], ",")
if !found || payload == "" {
return "", "", false
}
fields := strings.Split(metadata, ";")
mimeType = strings.TrimSpace(fields[0])
if mimeType == "" {
return "", "", false
}
for _, field := range fields[1:] {
if strings.EqualFold(strings.TrimSpace(field), "base64") {
return mimeType, payload, true
}
}
return "", "", false
}
@@ -0,0 +1,70 @@
package common
import "testing"
func TestNormalizeOpenAIFileData(t *testing.T) {
tests := []struct {
name string
filename string
fallbackMIME string
fileData string
wantMIMEType string
wantData string
wantOK bool
}{
{
name: "data URL",
filename: "test.pdf",
fileData: "data:application/pdf;base64,JVBERi0xLjQK",
wantMIMEType: "application/pdf",
wantData: "JVBERi0xLjQK",
wantOK: true,
},
{
name: "data URL metadata and MIME override",
filename: "test.txt",
fileData: "data:application/pdf;charset=binary;BASE64,JVBERi0xLjQK",
wantMIMEType: "application/pdf",
wantData: "JVBERi0xLjQK",
wantOK: true,
},
{
name: "case-insensitive data URL scheme",
filename: "test.pdf",
fileData: "DATA:application/pdf;base64,JVBERi0xLjQK",
wantMIMEType: "application/pdf",
wantData: "JVBERi0xLjQK",
wantOK: true,
},
{
name: "raw base64",
filename: "TEST.PDF",
fileData: "JVBERi0xLjQK",
wantMIMEType: "application/pdf",
wantData: "JVBERi0xLjQK",
wantOK: true,
},
{
name: "raw base64 with explicit MIME type",
fallbackMIME: "application/pdf",
fileData: "JVBERi0xLjQK",
wantMIMEType: "application/pdf",
wantData: "JVBERi0xLjQK",
wantOK: true,
},
{name: "empty data", filename: "test.pdf"},
{name: "raw base64 without known extension", filename: "test", fileData: "JVBERi0xLjQK"},
{name: "data URL without base64 marker", filename: "test.pdf", fileData: "data:application/pdf,JVBERi0xLjQK"},
{name: "data URL without MIME type", filename: "test.pdf", fileData: "data:;base64,JVBERi0xLjQK"},
{name: "data URL without payload", filename: "test.pdf", fileData: "data:application/pdf;base64,"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mimeType, data, ok := NormalizeOpenAIFileData(test.filename, test.fallbackMIME, test.fileData)
if mimeType != test.wantMIMEType || data != test.wantData || ok != test.wantOK {
t.Fatalf("NormalizeOpenAIFileData() = (%q, %q, %v), want (%q, %q, %v)", mimeType, data, ok, test.wantMIMEType, test.wantData, test.wantOK)
}
})
}
}
@@ -8,7 +8,6 @@ import (
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -878,12 +877,8 @@ func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte
case "file":
filename := part.Get("file.filename").String()
fileData := part.Get("file.file_data").String()
ext := ""
if sp := strings.Split(filename, "."); len(sp) > 1 {
ext = sp[len(sp)-1]
}
if mimeType, ok := misc.MimeTypes[ext]; ok && fileData != "" {
return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, fileData)))
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
return nil
@@ -0,0 +1,20 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToGeminiNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", input, false)
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
@@ -0,0 +1,20 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToGeminiNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToGemini("gemini-2.5-pro", input, false)
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("inlineData.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
@@ -5,7 +5,6 @@ package chat_completions
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
@@ -198,14 +197,10 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
case "file":
filename := item.Get("file.filename").String()
fileData := item.Get("file.file_data").String()
ext := ""
if pieces := strings.Split(filename, "."); len(pieces) > 1 {
ext = pieces[len(pieces)-1]
}
if mimeType, ok := misc.MimeTypes[ext]; ok {
partItems = append(partItems, geminiInlineDataPart(mimeType, fileData, ""))
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
partItems = append(partItems, geminiInlineDataPart(mimeType, data, ""))
} else {
log.Warnf("Unknown file name extension '%s' in user message, skip", ext)
log.Warn("Invalid file data or unknown file name extension in user message, skip")
}
case "input_audio":
audioData := item.Get("input_audio.data").String()
@@ -0,0 +1,33 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToInteractionsNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
func TestConvertOpenAIRequestToInteractionsPreservesRawFileDataWithMIMEType(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"document","mime_type":"application/pdf","data":"JVBERi0xLjQK"}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want unchanged raw base64 payload. Output: %s", got, out)
}
}
@@ -147,17 +147,25 @@ func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) {
return out, true
case "file", "input_file", "document":
file := part.Get("file")
filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String())
fallbackMIMEType := firstNonEmpty(file.Get("mime_type").String(), file.Get("mimeType").String(), part.Get("mime_type").String(), part.Get("mimeType").String())
fileData := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String())
fileURL := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String())
out := []byte(`{"type":"document"}`)
if filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String()); filename != "" {
if filename != "" {
out, _ = sjson.SetBytes(out, "filename", filename)
}
if data := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String()); data != "" {
hasContent := false
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
hasContent = true
}
if url := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String()); url != "" {
out, _ = sjson.SetBytes(out, "file_url", url)
if fileURL != "" {
out, _ = sjson.SetBytes(out, "file_url", fileURL)
hasContent = true
}
return out, true
return out, hasContent
}
return nil, false
}