mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
+20
@@ -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
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user