diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go new file mode 100644 index 00000000..4aa670a8 --- /dev/null +++ b/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go @@ -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) + } +} diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 4b420ef1..5fc445a4 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -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 diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go new file mode 100644 index 00000000..62701836 --- /dev/null +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go @@ -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) + } +} 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 d8cdf993..ad005316 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -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() diff --git a/internal/translator/common/file_data.go b/internal/translator/common/file_data.go new file mode 100644 index 00000000..fe6a0338 --- /dev/null +++ b/internal/translator/common/file_data.go @@ -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 +} diff --git a/internal/translator/common/file_data_test.go b/internal/translator/common/file_data_test.go new file mode 100644 index 00000000..e2e32e02 --- /dev/null +++ b/internal/translator/common/file_data_test.go @@ -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) + } + }) + } +} diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go index efc08397..e27e815e 100644 --- a/internal/translator/gemini/interactions/interactions_gemini_common.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -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 diff --git a/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go b/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go new file mode 100644 index 00000000..64ed1c0a --- /dev/null +++ b/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go @@ -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) + } +} diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go new file mode 100644 index 00000000..8c529685 --- /dev/null +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go @@ -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) + } +} 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 a468b6d4..abbb2dd0 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -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() diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go new file mode 100644 index 00000000..0bc53934 --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go @@ -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) + } +} diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go index 7d25a9cb..65dfee77 100644 --- a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go +++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go @@ -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 }