From b4bec344f6f732704f11cf5ad86a252a515c3880 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 13:34:05 +0800 Subject: [PATCH] feat(translator): sanitize `parametersJsonSchema` in OpenAI to Gemini request handling - Applied `CleanJSONSchemaForGemini` to ensure compatibility by removing unsupported fields and cleaning schema requirements. - Added test cases to validate schema transformation, ensuring only necessary `required` fields are retained. - Enhanced both request and response flows to consistently clean and sanitize tool parameter schemas. Closes: #3863 --- .../chat-completions/gemini_openai_request.go | 3 ++ .../gemini_openai_request_test.go | 43 +++++++++++++++++++ .../gemini_openai-responses_request.go | 2 +- .../gemini_openai-responses_request_test.go | 41 ++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) 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 031fea8f..f8ab5ddc 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -363,6 +363,9 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fnRawBytes := []byte(fnRaw) fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.SanitizeFunctionName(fn.Get("name").String())) fnRaw = string(fnRawBytes) + if parameters := gjson.Get(fnRaw, "parametersJsonSchema"); parameters.Exists() { + fnRaw, _ = sjson.SetRaw(fnRaw, "parametersJsonSchema", util.CleanJSONSchemaForGemini(parameters.Raw)) + } fnRaw, _ = sjson.Delete(fnRaw, "strict") if !hasFunction { functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte("[]")) diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go index 45971ad8..27e999e3 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go @@ -58,3 +58,46 @@ func TestConvertOpenAIRequestToGeminiPreservesInputAudio(t *testing.T) { t.Fatalf("audio data = %q, want %q", got, "SUQzBA==") } } + +func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) { + inputJSON := `{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [{ + "type": "function", + "function": { + "name": "search_company", + "description": "Search", + "parameters": { + "type": "object", + "title": "SearchCompany", + "properties": { + "country": {"type": "string"}, + "industry": {"type": "string"} + }, + "required": ["country", "industry", "stale_field", "another_stale"] + } + } + }] + }` + + output := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false) + schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema") + + if !schema.Exists() { + t.Fatalf("parametersJsonSchema missing. Output: %s", output) + } + if schema.Get("title").Exists() { + t.Fatalf("schema title should be removed. Output: %s", output) + } + required := schema.Get("required").Array() + if len(required) != 2 { + t.Fatalf("required length = %d, want 2. Schema: %s", len(required), schema.Raw) + } + if got := required[0].String(); got != "country" { + t.Fatalf("required[0] = %q, want country. Schema: %s", got, schema.Raw) + } + if got := required[1].String(); got != "industry" { + t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index f38df578..b9a6efe6 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -395,7 +395,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte funcDecl, _ = sjson.SetBytes(funcDecl, "description", desc.String()) } if params := tool.Get("parameters"); params.Exists() { - funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(params.Raw)) + funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(util.CleanJSONSchemaForGemini(params.Raw))) } geminiTools, _ = sjson.SetRawBytes(geminiTools, "0.functionDeclarations.-1", funcDecl) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go index 1bd14145..446ee753 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -245,6 +245,47 @@ func TestConvertOpenAIResponsesRequestToGemini_SystemAndDeveloperRoles(t *testin } } +func TestConvertOpenAIResponsesRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) { + inputJSON := `{ + "model": "gemini-2.0-flash", + "input": "hi", + "tools": [{ + "type": "function", + "name": "search_company", + "description": "Search", + "parameters": { + "type": "object", + "title": "SearchCompany", + "properties": { + "country": {"type": "string"}, + "industry": {"type": "string"} + }, + "required": ["country", "industry", "stale_field", "another_stale"] + } + }] + }` + + output := ConvertOpenAIResponsesRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false) + schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema") + + if !schema.Exists() { + t.Fatalf("parametersJsonSchema missing. Output: %s", output) + } + if schema.Get("title").Exists() { + t.Fatalf("schema title should be removed. Output: %s", output) + } + required := schema.Get("required").Array() + if len(required) != 2 { + t.Fatalf("required length = %d, want 2. Schema: %s", len(required), schema.Raw) + } + if got := required[0].String(); got != "country" { + t.Fatalf("required[0] = %q, want country. Schema: %s", got, schema.Raw) + } + if got := required[1].String(); got != "industry" { + t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw) + } +} + func validResponsesGPTReasoningSignature() string { raw := make([]byte, 1+8+16+16+32) raw[0] = 0x80