From 8652530015af144fa23dc002ffc9d238d2612fc2 Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Tue, 21 Oct 2025 11:41:42 -0400 Subject: [PATCH 1/7] fix: remove wrapping double-quotes from GCP guided_regex Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper.go | 51 +++++++++++++++---- .../extproc/translator/gemini_helper_test.go | 2 +- .../extproc/translator/openai_gcpvertexai.go | 8 +-- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/internal/extproc/translator/gemini_helper.go b/internal/extproc/translator/gemini_helper.go index 325d891bb..e61ee91c7 100644 --- a/internal/extproc/translator/gemini_helper.go +++ b/internal/extproc/translator/gemini_helper.go @@ -29,6 +29,17 @@ const ( httpHeaderKeyContentLength = "Content-Length" ) +// ResponseMode represents the type of response mode for Gemini requests +type ResponseMode string + +const ( + ResponseModeNone ResponseMode = "NONE" + ResponseModeText ResponseMode = "TEXT" + ResponseModeJSON ResponseMode = "JSON" + ResponseModeEnum ResponseMode = "ENUM" + ResponseModeRegex ResponseMode = "REGEX" +) + // ------------------------------------------------------------- // Request Conversion Helper for OpenAI to GCP Gemini Translator // -------------------------------------------------------------. @@ -382,7 +393,8 @@ func openAIToolChoiceToGeminiToolConfig(toolChoice *openai.ChatCompletionToolCho } // openAIReqToGeminiGenerationConfig converts OpenAI request to Gemini GenerationConfig. -func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) (*genai.GenerationConfig, error) { +func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) (*genai.GenerationConfig, ResponseMode, error) { + responseMode := ResponseModeNone gc := &genai.GenerationConfig{} if openAIReq.Temperature != nil { f := float32(*openAIReq.Temperature) @@ -410,15 +422,19 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) if openAIReq.ResponseFormat != nil { switch { case openAIReq.ResponseFormat.OfText != nil: + responseMode = ResponseModeText gc.ResponseMIMEType = mimeTypeTextPlain case openAIReq.ResponseFormat.OfJSONObject != nil: + responseMode = ResponseModeJSON gc.ResponseMIMEType = mimeTypeApplicationJSON case openAIReq.ResponseFormat.OfJSONSchema != nil: var schemaMap map[string]any if err := json.Unmarshal([]byte(openAIReq.ResponseFormat.OfJSONSchema.JSONSchema.Schema), &schemaMap); err != nil { - return nil, fmt.Errorf("invalid JSON schema: %w", err) + return nil, responseMode, fmt.Errorf("invalid JSON schema: %w", err) } + responseMode = ResponseModeJSON + gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseJsonSchema = schemaMap } @@ -426,23 +442,27 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) if openAIReq.GuidedChoice != nil { if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { - return nil, fmt.Errorf("duplicate json scheme specifications") + return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } + responseMode = ResponseModeEnum gc.ResponseMIMEType = mimeTypeApplicationEnum gc.ResponseSchema = &genai.Schema{Type: "STRING", Enum: openAIReq.GuidedChoice} } if openAIReq.GuidedRegex != "" { if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { - return nil, fmt.Errorf("duplicate json scheme specifications") + return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } + responseMode = ResponseModeRegex gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseSchema = &genai.Schema{Type: "STRING", Pattern: openAIReq.GuidedRegex} } if openAIReq.GuidedJSON != nil { if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { - return nil, fmt.Errorf("duplicate json scheme specifications") + return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } + responseMode = ResponseModeJSON + gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseJsonSchema = openAIReq.GuidedJSON } @@ -464,7 +484,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) } else if openAIReq.Stop.OfStringArray != nil { gc.StopSequences = openAIReq.Stop.OfStringArray } - return gc, nil + return gc, responseMode, nil } // -------------------------------------------------------------- @@ -472,7 +492,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) // --------------------------------------------------------------. // geminiCandidatesToOpenAIChoices converts Gemini candidates to OpenAI choices. -func geminiCandidatesToOpenAIChoices(candidates []*genai.Candidate) ([]openai.ChatCompletionResponseChoice, error) { +func geminiCandidatesToOpenAIChoices(candidates []*genai.Candidate, responseMode ResponseMode) ([]openai.ChatCompletionResponseChoice, error) { choices := make([]openai.ChatCompletionResponseChoice, 0, len(candidates)) for idx, candidate := range candidates { @@ -491,7 +511,7 @@ func geminiCandidatesToOpenAIChoices(candidates []*genai.Candidate) ([]openai.Ch Role: openai.ChatMessageRoleAssistant, } // Extract text from parts. - content := extractTextFromGeminiParts(candidate.Content.Parts) + content := extractTextFromGeminiParts(candidate.Content.Parts, responseMode) message.Content = &content // Extract tool calls if any. @@ -545,10 +565,19 @@ func geminiFinishReasonToOpenAI(reason genai.FinishReason) openai.ChatCompletion } // extractTextFromGeminiParts extracts text from Gemini parts. -func extractTextFromGeminiParts(parts []*genai.Part) string { +func extractTextFromGeminiParts(parts []*genai.Part, responseMode ResponseMode) string { var text string for _, part := range parts { if part != nil && part.Text != "" { + if responseMode == ResponseModeRegex { + // GCP doesn't natively support REGEX response modes, so we instead express them as json schema. + // This causes the response to be wrapped in double-quotes. + // E.g. `"positive"` (the double-quotes at the start and end are unwanted) + // Here we remove the wrapping double-quotes. + if len(part.Text) > 2 && part.Text[0] == '"' && part.Text[len(part.Text)-1] == '"' { + part.Text = part.Text[1 : len(part.Text)-1] + } + } text += part.Text } } @@ -665,7 +694,7 @@ func buildGCPModelPathSuffix(publisher, model, gcpMethod string, queryParams ... } // geminiCandidatesToOpenAIStreamingChoices converts Gemini candidates to OpenAI streaming choices. -func geminiCandidatesToOpenAIStreamingChoices(candidates []*genai.Candidate) ([]openai.ChatCompletionResponseChunkChoice, error) { +func geminiCandidatesToOpenAIStreamingChoices(candidates []*genai.Candidate, responseMode ResponseMode) ([]openai.ChatCompletionResponseChunkChoice, error) { choices := make([]openai.ChatCompletionResponseChunkChoice, 0, len(candidates)) for _, candidate := range candidates { @@ -685,7 +714,7 @@ func geminiCandidatesToOpenAIStreamingChoices(candidates []*genai.Candidate) ([] } // Extract text from parts for streaming (delta). - content := extractTextFromGeminiParts(candidate.Content.Parts) + content := extractTextFromGeminiParts(candidate.Content.Parts, responseMode) if content != "" { delta.Content = &content } diff --git a/internal/extproc/translator/gemini_helper_test.go b/internal/extproc/translator/gemini_helper_test.go index 3263e2005..56bb8f900 100644 --- a/internal/extproc/translator/gemini_helper_test.go +++ b/internal/extproc/translator/gemini_helper_test.go @@ -876,7 +876,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := openAIReqToGeminiGenerationConfig(tc.input) + got, _, err := openAIReqToGeminiGenerationConfig(tc.input) if tc.expectedErrMsg != "" { require.ErrorContains(t, err, tc.expectedErrMsg) } else { diff --git a/internal/extproc/translator/openai_gcpvertexai.go b/internal/extproc/translator/openai_gcpvertexai.go index 35708e745..602ab220b 100644 --- a/internal/extproc/translator/openai_gcpvertexai.go +++ b/internal/extproc/translator/openai_gcpvertexai.go @@ -48,6 +48,7 @@ func NewChatCompletionOpenAIToGCPVertexAITranslator(modelNameOverride internalap // Note: This uses the Gemini native API directly, not Vertex AI's OpenAI-compatible API: // https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference type openAIToGCPVertexAITranslatorV1ChatCompletion struct { + responseMode ResponseMode modelNameOverride internalapi.ModelNameOverride stream bool // Track if this is a streaming request. bufferedBody []byte // Buffer for incomplete JSON chunks. @@ -244,7 +245,7 @@ func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) parseGCPStreamingChunks( // convertGCPChunkToOpenAI converts a GCP streaming chunk to OpenAI streaming format. func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) convertGCPChunkToOpenAI(chunk genai.GenerateContentResponse) *openai.ChatCompletionResponseChunk { // Convert candidates to OpenAI choices for streaming. - choices, err := geminiCandidatesToOpenAIStreamingChoices(chunk.Candidates) + choices, err := geminiCandidatesToOpenAIStreamingChoices(chunk.Candidates, o.responseMode) if err != nil { // For now, create empty choices on error to prevent breaking the stream. choices = []openai.ChatCompletionResponseChunkChoice{} @@ -284,10 +285,11 @@ func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) openAIMessageToGeminiMes } // Convert generation config. - generationConfig, err := openAIReqToGeminiGenerationConfig(openAIReq) + generationConfig, responseMode, err := openAIReqToGeminiGenerationConfig(openAIReq) if err != nil { return nil, fmt.Errorf("error converting generation config: %w", err) } + o.responseMode = responseMode gcr := gcp.GenerateContentRequest{ Contents: contents, @@ -330,7 +332,7 @@ func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) applyVendorSpecificField func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) geminiResponseToOpenAIMessage(gcr genai.GenerateContentResponse, responseModel string) (*openai.ChatCompletionResponse, error) { // Convert candidates to OpenAI choices. - choices, err := geminiCandidatesToOpenAIChoices(gcr.Candidates) + choices, err := geminiCandidatesToOpenAIChoices(gcr.Candidates, o.responseMode) if err != nil { return nil, fmt.Errorf("error converting choices: %w", err) } From 2d665a32421af5e08a4994cdd172c6f6713b040a Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Tue, 21 Oct 2025 14:09:12 -0400 Subject: [PATCH 2/7] tests Signed-off-by: Sukumar Gaonkar --- .../extproc/translator/gemini_helper_test.go | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/internal/extproc/translator/gemini_helper_test.go b/internal/extproc/translator/gemini_helper_test.go index 56bb8f900..2e1767ca9 100644 --- a/internal/extproc/translator/gemini_helper_test.go +++ b/internal/extproc/translator/gemini_helper_test.go @@ -725,6 +725,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { name string input *openai.ChatCompletionRequest expectedGenerationConfig *genai.GenerationConfig + expectedResponseMode ResponseMode expectedErrMsg string }{ { @@ -755,11 +756,13 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { FrequencyPenalty: ptr.To(float32(0.5)), StopSequences: []string{"stop1", "stop2"}, }, + expectedResponseMode: ResponseModeNone, }, { name: "minimal fields", input: &openai.ChatCompletionRequest{}, expectedGenerationConfig: &genai.GenerationConfig{}, + expectedResponseMode: ResponseModeNone, }, { name: "stop sequences", @@ -771,6 +774,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { expectedGenerationConfig: &genai.GenerationConfig{ StopSequences: []string{"stop1"}, }, + expectedResponseMode: ResponseModeNone, }, { name: "text", @@ -782,6 +786,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { }, }, expectedGenerationConfig: &genai.GenerationConfig{ResponseMIMEType: "text/plain"}, + expectedResponseMode: ResponseModeText, }, { name: "json object", @@ -793,6 +798,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { }, }, expectedGenerationConfig: &genai.GenerationConfig{ResponseMIMEType: "application/json"}, + expectedResponseMode: ResponseModeJSON, }, { name: "json schema (map)", @@ -810,6 +816,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: map[string]any{"type": "string"}, }, + expectedResponseMode: ResponseModeJSON, }, { name: "json schema (string)", @@ -827,6 +834,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: map[string]any{"type": "string"}, }, + expectedResponseMode: ResponseModeJSON, }, { name: "json schema (invalid string)", @@ -851,6 +859,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "text/x.enum", ResponseSchema: &genai.Schema{Type: "STRING", Enum: []string{"Positive", "Negative"}}, }, + expectedResponseMode: ResponseModeEnum, }, { name: "guided regex", @@ -861,6 +870,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseSchema: &genai.Schema{Type: "STRING", Pattern: "\\w+@\\w+\\.com\\n"}, }, + expectedResponseMode: ResponseModeRegex, }, { name: "guided json", @@ -871,12 +881,13 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: json.RawMessage(`{"type": "string"}`), }, + expectedResponseMode: ResponseModeJSON, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, _, err := openAIReqToGeminiGenerationConfig(tc.input) + got, responseMode, err := openAIReqToGeminiGenerationConfig(tc.input) if tc.expectedErrMsg != "" { require.ErrorContains(t, err, tc.expectedErrMsg) } else { @@ -885,6 +896,10 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { if diff := cmp.Diff(tc.expectedGenerationConfig, got, cmpopts.IgnoreUnexported(genai.GenerationConfig{})); diff != "" { t.Errorf("GenerationConfig mismatch (-want +got):\n%s", diff) } + + if responseMode != tc.expectedResponseMode { + t.Errorf("ResponseMode mismatch: got %v, want %v", responseMode, tc.expectedResponseMode) + } } }) } @@ -1388,3 +1403,69 @@ func TestGeminiFinishReasonToOpenAI(t *testing.T) { }) } } + +func TestExtractTextFromGeminiParts(t *testing.T) { + tests := []struct { + name string + parts []*genai.Part + responseMode ResponseMode + expected string + }{ + { + name: "nil parts", + parts: nil, + responseMode: ResponseModeNone, + expected: "", + }, + { + name: "empty parts", + parts: []*genai.Part{}, + responseMode: ResponseModeNone, + expected: "", + }, + { + name: "multiple text parts without regex mode", + parts: []*genai.Part{ + {Text: "Hello, "}, + {Text: "world!"}, + }, + responseMode: ResponseModeJSON, + expected: "Hello, world!", + }, + { + name: "regex mode with mixed quoted and unquoted text", + parts: []*genai.Part{ + {Text: `"positive"`}, + {Text: `unquoted`}, + {Text: `"negative"`}, + }, + responseMode: ResponseModeRegex, + expected: "positiveunquotednegative", + }, + { + name: "non-regex mode with double-quoted text (should not remove quotes)", + parts: []*genai.Part{ + {Text: `"positive"`}, + }, + responseMode: ResponseModeJSON, + expected: `"positive"`, + }, + { + name: "regex mode with text containing internal quotes", + parts: []*genai.Part{ + {Text: `"He said \"hello\" to me"`}, + }, + responseMode: ResponseModeRegex, + expected: `He said \"hello\" to me`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := extractTextFromGeminiParts(tc.parts, tc.responseMode) + if result != tc.expected { + t.Errorf("extractTextFromGeminiParts() = %q, want %q", result, tc.expected) + } + }) + } +} From eeefb3d53712552cedc5a38effe1c1fc186494c1 Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Wed, 22 Oct 2025 09:56:41 -0400 Subject: [PATCH 3/7] add test case for edge case Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/extproc/translator/gemini_helper_test.go b/internal/extproc/translator/gemini_helper_test.go index 2e1767ca9..f6a2e8121 100644 --- a/internal/extproc/translator/gemini_helper_test.go +++ b/internal/extproc/translator/gemini_helper_test.go @@ -1442,6 +1442,14 @@ func TestExtractTextFromGeminiParts(t *testing.T) { responseMode: ResponseModeRegex, expected: "positiveunquotednegative", }, + { + name: "regex mode with only double-quoted first and last words", + parts: []*genai.Part{ + {Text: "\"\"ERROR\" Unable to connect to database \"DatabaseModule\"\""}, + }, + responseMode: ResponseModeRegex, + expected: "\"ERROR\" Unable to connect to database \"DatabaseModule\"", + }, { name: "non-regex mode with double-quoted text (should not remove quotes)", parts: []*genai.Part{ From 9bc5b8eee3a2cb994fb1253fb30c3564e26e1ca0 Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Wed, 22 Oct 2025 10:00:46 -0400 Subject: [PATCH 4/7] validate mutual exclusivity of response format specifiers in guided requests Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper.go | 12 ++++++++++++ internal/extproc/translator/gemini_helper_test.go | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/extproc/translator/gemini_helper.go b/internal/extproc/translator/gemini_helper.go index e61ee91c7..949aa805f 100644 --- a/internal/extproc/translator/gemini_helper.go +++ b/internal/extproc/translator/gemini_helper.go @@ -419,7 +419,10 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) gc.ResponseLogprobs = *openAIReq.LogProbs } + formatSpecifiedCount := 0 + if openAIReq.ResponseFormat != nil { + formatSpecifiedCount++ switch { case openAIReq.ResponseFormat.OfText != nil: responseMode = ResponseModeText @@ -441,6 +444,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) } if openAIReq.GuidedChoice != nil { + formatSpecifiedCount++ if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } @@ -450,6 +454,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) gc.ResponseSchema = &genai.Schema{Type: "STRING", Enum: openAIReq.GuidedChoice} } if openAIReq.GuidedRegex != "" { + formatSpecifiedCount++ if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } @@ -458,6 +463,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) gc.ResponseSchema = &genai.Schema{Type: "STRING", Pattern: openAIReq.GuidedRegex} } if openAIReq.GuidedJSON != nil { + formatSpecifiedCount++ if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } @@ -467,6 +473,12 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) gc.ResponseJsonSchema = openAIReq.GuidedJSON } + // ResponseFormat and guidedJSON/guidedChoice/guidedRegex are mutually exclusive. + // Verify only one is specified. + if formatSpecifiedCount > 1 { + return nil, responseMode, fmt.Errorf("multiple format specifiers specified. only one of responseFormat, guidedChoice, guidedRegex, guidedJSON can be specified") + } + if openAIReq.N != nil { gc.CandidateCount = int32(*openAIReq.N) // nolint:gosec } diff --git a/internal/extproc/translator/gemini_helper_test.go b/internal/extproc/translator/gemini_helper_test.go index f6a2e8121..ec2948a30 100644 --- a/internal/extproc/translator/gemini_helper_test.go +++ b/internal/extproc/translator/gemini_helper_test.go @@ -883,6 +883,18 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { }, expectedResponseMode: ResponseModeJSON, }, + { + name: "multiple format specifiers - ResponseFormat and GuidedChoice", + input: &openai.ChatCompletionRequest{ + ResponseFormat: &openai.ChatCompletionResponseFormatUnion{ + OfText: &openai.ChatCompletionResponseFormatTextParam{ + Type: openai.ChatCompletionResponseFormatTypeText, + }, + }, + GuidedChoice: []string{"A", "B"}, + }, + expectedErrMsg: "multiple format specifiers specified", + }, } for _, tc := range tests { From f871ff7d7c0a42b8e4129ad461aef5726d45ce28 Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Wed, 22 Oct 2025 12:41:40 -0400 Subject: [PATCH 5/7] address pr comments (refactoring) Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper.go | 38 +++++++++--------- .../extproc/translator/gemini_helper_test.go | 40 +++++++++---------- .../extproc/translator/openai_gcpvertexai.go | 2 +- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/internal/extproc/translator/gemini_helper.go b/internal/extproc/translator/gemini_helper.go index 949aa805f..8d9aa4533 100644 --- a/internal/extproc/translator/gemini_helper.go +++ b/internal/extproc/translator/gemini_helper.go @@ -29,15 +29,15 @@ const ( httpHeaderKeyContentLength = "Content-Length" ) -// ResponseMode represents the type of response mode for Gemini requests -type ResponseMode string +// geminiResponseMode represents the type of response mode for Gemini requests +type geminiResponseMode string const ( - ResponseModeNone ResponseMode = "NONE" - ResponseModeText ResponseMode = "TEXT" - ResponseModeJSON ResponseMode = "JSON" - ResponseModeEnum ResponseMode = "ENUM" - ResponseModeRegex ResponseMode = "REGEX" + responseModeNone geminiResponseMode = "NONE" + responseModeText geminiResponseMode = "TEXT" + responseModeJSON geminiResponseMode = "JSON" + responseModeEnum geminiResponseMode = "ENUM" + responseModeRegex geminiResponseMode = "REGEX" ) // ------------------------------------------------------------- @@ -393,8 +393,8 @@ func openAIToolChoiceToGeminiToolConfig(toolChoice *openai.ChatCompletionToolCho } // openAIReqToGeminiGenerationConfig converts OpenAI request to Gemini GenerationConfig. -func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) (*genai.GenerationConfig, ResponseMode, error) { - responseMode := ResponseModeNone +func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) (*genai.GenerationConfig, geminiResponseMode, error) { + responseMode := responseModeNone gc := &genai.GenerationConfig{} if openAIReq.Temperature != nil { f := float32(*openAIReq.Temperature) @@ -425,10 +425,10 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) formatSpecifiedCount++ switch { case openAIReq.ResponseFormat.OfText != nil: - responseMode = ResponseModeText + responseMode = responseModeText gc.ResponseMIMEType = mimeTypeTextPlain case openAIReq.ResponseFormat.OfJSONObject != nil: - responseMode = ResponseModeJSON + responseMode = responseModeJSON gc.ResponseMIMEType = mimeTypeApplicationJSON case openAIReq.ResponseFormat.OfJSONSchema != nil: var schemaMap map[string]any @@ -436,7 +436,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) return nil, responseMode, fmt.Errorf("invalid JSON schema: %w", err) } - responseMode = ResponseModeJSON + responseMode = responseModeJSON gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseJsonSchema = schemaMap @@ -449,7 +449,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } - responseMode = ResponseModeEnum + responseMode = responseModeEnum gc.ResponseMIMEType = mimeTypeApplicationEnum gc.ResponseSchema = &genai.Schema{Type: "STRING", Enum: openAIReq.GuidedChoice} } @@ -458,7 +458,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } - responseMode = ResponseModeRegex + responseMode = responseModeRegex gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseSchema = &genai.Schema{Type: "STRING", Pattern: openAIReq.GuidedRegex} } @@ -467,7 +467,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) if existSchema := gc.ResponseSchema != nil || gc.ResponseJsonSchema != nil; existSchema { return nil, responseMode, fmt.Errorf("duplicate json scheme specifications") } - responseMode = ResponseModeJSON + responseMode = responseModeJSON gc.ResponseMIMEType = mimeTypeApplicationJSON gc.ResponseJsonSchema = openAIReq.GuidedJSON @@ -504,7 +504,7 @@ func openAIReqToGeminiGenerationConfig(openAIReq *openai.ChatCompletionRequest) // --------------------------------------------------------------. // geminiCandidatesToOpenAIChoices converts Gemini candidates to OpenAI choices. -func geminiCandidatesToOpenAIChoices(candidates []*genai.Candidate, responseMode ResponseMode) ([]openai.ChatCompletionResponseChoice, error) { +func geminiCandidatesToOpenAIChoices(candidates []*genai.Candidate, responseMode geminiResponseMode) ([]openai.ChatCompletionResponseChoice, error) { choices := make([]openai.ChatCompletionResponseChoice, 0, len(candidates)) for idx, candidate := range candidates { @@ -577,11 +577,11 @@ func geminiFinishReasonToOpenAI(reason genai.FinishReason) openai.ChatCompletion } // extractTextFromGeminiParts extracts text from Gemini parts. -func extractTextFromGeminiParts(parts []*genai.Part, responseMode ResponseMode) string { +func extractTextFromGeminiParts(parts []*genai.Part, responseMode geminiResponseMode) string { var text string for _, part := range parts { if part != nil && part.Text != "" { - if responseMode == ResponseModeRegex { + if responseMode == responseModeRegex { // GCP doesn't natively support REGEX response modes, so we instead express them as json schema. // This causes the response to be wrapped in double-quotes. // E.g. `"positive"` (the double-quotes at the start and end are unwanted) @@ -706,7 +706,7 @@ func buildGCPModelPathSuffix(publisher, model, gcpMethod string, queryParams ... } // geminiCandidatesToOpenAIStreamingChoices converts Gemini candidates to OpenAI streaming choices. -func geminiCandidatesToOpenAIStreamingChoices(candidates []*genai.Candidate, responseMode ResponseMode) ([]openai.ChatCompletionResponseChunkChoice, error) { +func geminiCandidatesToOpenAIStreamingChoices(candidates []*genai.Candidate, responseMode geminiResponseMode) ([]openai.ChatCompletionResponseChunkChoice, error) { choices := make([]openai.ChatCompletionResponseChunkChoice, 0, len(candidates)) for _, candidate := range candidates { diff --git a/internal/extproc/translator/gemini_helper_test.go b/internal/extproc/translator/gemini_helper_test.go index ec2948a30..8619c97cd 100644 --- a/internal/extproc/translator/gemini_helper_test.go +++ b/internal/extproc/translator/gemini_helper_test.go @@ -725,7 +725,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { name string input *openai.ChatCompletionRequest expectedGenerationConfig *genai.GenerationConfig - expectedResponseMode ResponseMode + expectedResponseMode geminiResponseMode expectedErrMsg string }{ { @@ -756,13 +756,13 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { FrequencyPenalty: ptr.To(float32(0.5)), StopSequences: []string{"stop1", "stop2"}, }, - expectedResponseMode: ResponseModeNone, + expectedResponseMode: responseModeNone, }, { name: "minimal fields", input: &openai.ChatCompletionRequest{}, expectedGenerationConfig: &genai.GenerationConfig{}, - expectedResponseMode: ResponseModeNone, + expectedResponseMode: responseModeNone, }, { name: "stop sequences", @@ -774,7 +774,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { expectedGenerationConfig: &genai.GenerationConfig{ StopSequences: []string{"stop1"}, }, - expectedResponseMode: ResponseModeNone, + expectedResponseMode: responseModeNone, }, { name: "text", @@ -786,7 +786,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { }, }, expectedGenerationConfig: &genai.GenerationConfig{ResponseMIMEType: "text/plain"}, - expectedResponseMode: ResponseModeText, + expectedResponseMode: responseModeText, }, { name: "json object", @@ -798,7 +798,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { }, }, expectedGenerationConfig: &genai.GenerationConfig{ResponseMIMEType: "application/json"}, - expectedResponseMode: ResponseModeJSON, + expectedResponseMode: responseModeJSON, }, { name: "json schema (map)", @@ -816,7 +816,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: map[string]any{"type": "string"}, }, - expectedResponseMode: ResponseModeJSON, + expectedResponseMode: responseModeJSON, }, { name: "json schema (string)", @@ -834,7 +834,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: map[string]any{"type": "string"}, }, - expectedResponseMode: ResponseModeJSON, + expectedResponseMode: responseModeJSON, }, { name: "json schema (invalid string)", @@ -859,7 +859,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "text/x.enum", ResponseSchema: &genai.Schema{Type: "STRING", Enum: []string{"Positive", "Negative"}}, }, - expectedResponseMode: ResponseModeEnum, + expectedResponseMode: responseModeEnum, }, { name: "guided regex", @@ -870,7 +870,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseSchema: &genai.Schema{Type: "STRING", Pattern: "\\w+@\\w+\\.com\\n"}, }, - expectedResponseMode: ResponseModeRegex, + expectedResponseMode: responseModeRegex, }, { name: "guided json", @@ -881,7 +881,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { ResponseMIMEType: "application/json", ResponseJsonSchema: json.RawMessage(`{"type": "string"}`), }, - expectedResponseMode: ResponseModeJSON, + expectedResponseMode: responseModeJSON, }, { name: "multiple format specifiers - ResponseFormat and GuidedChoice", @@ -910,7 +910,7 @@ func TestOpenAIReqToGeminiGenerationConfig(t *testing.T) { } if responseMode != tc.expectedResponseMode { - t.Errorf("ResponseMode mismatch: got %v, want %v", responseMode, tc.expectedResponseMode) + t.Errorf("geminiResponseMode mismatch: got %v, want %v", responseMode, tc.expectedResponseMode) } } }) @@ -1420,19 +1420,19 @@ func TestExtractTextFromGeminiParts(t *testing.T) { tests := []struct { name string parts []*genai.Part - responseMode ResponseMode + responseMode geminiResponseMode expected string }{ { name: "nil parts", parts: nil, - responseMode: ResponseModeNone, + responseMode: responseModeNone, expected: "", }, { name: "empty parts", parts: []*genai.Part{}, - responseMode: ResponseModeNone, + responseMode: responseModeNone, expected: "", }, { @@ -1441,7 +1441,7 @@ func TestExtractTextFromGeminiParts(t *testing.T) { {Text: "Hello, "}, {Text: "world!"}, }, - responseMode: ResponseModeJSON, + responseMode: responseModeJSON, expected: "Hello, world!", }, { @@ -1451,7 +1451,7 @@ func TestExtractTextFromGeminiParts(t *testing.T) { {Text: `unquoted`}, {Text: `"negative"`}, }, - responseMode: ResponseModeRegex, + responseMode: responseModeRegex, expected: "positiveunquotednegative", }, { @@ -1459,7 +1459,7 @@ func TestExtractTextFromGeminiParts(t *testing.T) { parts: []*genai.Part{ {Text: "\"\"ERROR\" Unable to connect to database \"DatabaseModule\"\""}, }, - responseMode: ResponseModeRegex, + responseMode: responseModeRegex, expected: "\"ERROR\" Unable to connect to database \"DatabaseModule\"", }, { @@ -1467,7 +1467,7 @@ func TestExtractTextFromGeminiParts(t *testing.T) { parts: []*genai.Part{ {Text: `"positive"`}, }, - responseMode: ResponseModeJSON, + responseMode: responseModeJSON, expected: `"positive"`, }, { @@ -1475,7 +1475,7 @@ func TestExtractTextFromGeminiParts(t *testing.T) { parts: []*genai.Part{ {Text: `"He said \"hello\" to me"`}, }, - responseMode: ResponseModeRegex, + responseMode: responseModeRegex, expected: `He said \"hello\" to me`, }, } diff --git a/internal/extproc/translator/openai_gcpvertexai.go b/internal/extproc/translator/openai_gcpvertexai.go index 602ab220b..7e1fb8731 100644 --- a/internal/extproc/translator/openai_gcpvertexai.go +++ b/internal/extproc/translator/openai_gcpvertexai.go @@ -48,7 +48,7 @@ func NewChatCompletionOpenAIToGCPVertexAITranslator(modelNameOverride internalap // Note: This uses the Gemini native API directly, not Vertex AI's OpenAI-compatible API: // https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference type openAIToGCPVertexAITranslatorV1ChatCompletion struct { - responseMode ResponseMode + responseMode geminiResponseMode modelNameOverride internalapi.ModelNameOverride stream bool // Track if this is a streaming request. bufferedBody []byte // Buffer for incomplete JSON chunks. From c73f6aa01cacc0405808ca927a8dc3c8736c824d Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Thu, 23 Oct 2025 15:03:24 -0400 Subject: [PATCH 6/7] pr comment - use strings trimPrefix trimSuffix Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/extproc/translator/gemini_helper.go b/internal/extproc/translator/gemini_helper.go index 8d9aa4533..88e9b702b 100644 --- a/internal/extproc/translator/gemini_helper.go +++ b/internal/extproc/translator/gemini_helper.go @@ -587,7 +587,8 @@ func extractTextFromGeminiParts(parts []*genai.Part, responseMode geminiResponse // E.g. `"positive"` (the double-quotes at the start and end are unwanted) // Here we remove the wrapping double-quotes. if len(part.Text) > 2 && part.Text[0] == '"' && part.Text[len(part.Text)-1] == '"' { - part.Text = part.Text[1 : len(part.Text)-1] + part.Text = strings.TrimPrefix(part.Text, "\"") + part.Text = strings.TrimSuffix(part.Text, "\"") } } text += part.Text From f2d8620e8e7f5244446e5bd8563bf5fd7a6dd6f4 Mon Sep 17 00:00:00 2001 From: Sukumar Gaonkar Date: Thu, 23 Oct 2025 16:00:41 -0400 Subject: [PATCH 7/7] address pr comment Signed-off-by: Sukumar Gaonkar --- internal/extproc/translator/gemini_helper.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/extproc/translator/gemini_helper.go b/internal/extproc/translator/gemini_helper.go index 88e9b702b..46e1fe8e3 100644 --- a/internal/extproc/translator/gemini_helper.go +++ b/internal/extproc/translator/gemini_helper.go @@ -586,10 +586,8 @@ func extractTextFromGeminiParts(parts []*genai.Part, responseMode geminiResponse // This causes the response to be wrapped in double-quotes. // E.g. `"positive"` (the double-quotes at the start and end are unwanted) // Here we remove the wrapping double-quotes. - if len(part.Text) > 2 && part.Text[0] == '"' && part.Text[len(part.Text)-1] == '"' { - part.Text = strings.TrimPrefix(part.Text, "\"") - part.Text = strings.TrimSuffix(part.Text, "\"") - } + part.Text = strings.TrimPrefix(part.Text, "\"") + part.Text = strings.TrimSuffix(part.Text, "\"") } text += part.Text }