fix: preserve gemini thought signatures (#25933) (#26169) · coder/coder@9595e6c
@@ -6,16 +6,13 @@ import (
66"io"
77"net/http"
88"strings"
9+10+"github.com/coder/coder/v2/internal/googleopenai"
911)
10121113// OpenAI-compatible providers share an API shape but differ in the exact JSON
1214// they accept. These patches adjust Fantasy's serialized request body at the
1315// transport boundary so higher-level generation code can stay provider agnostic.
14-//
15-// googleOpenAICompatDummyThoughtSignature is Google's documented last-resort
16-// bypass for callers that cannot preserve a real Gemini thought signature.
17-// See https://ai.google.dev/gemini-api/docs/thought-signatures.
18-const googleOpenAICompatDummyThoughtSignature = "skip_thought_signature_validator"
19162017func withOpenAICompatRequestPatches(
2118client *http.Client,
@@ -91,8 +88,8 @@ func patchOpenAICompatChatCompletionsBody(body []byte, baseURL string, modelID s
9188 }
92899390changed := rewriteOpenAICompatSingleToolChoice(payload)
94-if shouldAddGoogleOpenAICompatThoughtSignatures(baseURL, modelID) {
95-changed = addGoogleOpenAICompatThoughtSignatures(payload) || changed
91+if googleopenai.ShouldPatchOpenAICompatRequest(baseURL, modelID) {
92+changed = googleopenai.AddThoughtSignaturesToLatestTurn(payload) || changed
9693 }
9794if !changed {
9895return body
@@ -144,93 +141,3 @@ func rewriteOpenAICompatSingleToolChoice(payload map[string]any) bool {
144141payload["tool_choice"] = "required"
145142return true
146143}
147-148-// shouldAddGoogleOpenAICompatThoughtSignatures detects direct Gemini OpenAI
149-// endpoints and Coder AI Bridge Gemini routes. Other gateways, such as Vercel,
150-// keep their own provider-specific compatibility behavior.
151-func shouldAddGoogleOpenAICompatThoughtSignatures(baseURL string, modelID string) bool {
152-parsed, ok := parseProviderBaseURL(baseURL)
153-if !ok {
154-return false
155- }
156-host := strings.ToLower(parsed.Hostname())
157-path := strings.ToLower(parsed.EscapedPath())
158-if host == "generativelanguage.googleapis.com" && strings.Contains(path, "/openai") {
159-return true
160- }
161-return host == "coder-aibridge" && isGeminiModelID(modelID)
162-}
163-164-func isGeminiModelID(modelID string) bool {
165-modelID = strings.ToLower(strings.TrimSpace(modelID))
166-return strings.HasPrefix(modelID, "gemini-") || strings.Contains(modelID, "/gemini-")
167-}
168-169-// addGoogleOpenAICompatThoughtSignatures adds a dummy thought signature to the
170-// first tool call on each assistant tool-call message in the latest user turn.
171-// Gemini validates tool-call history with thought signatures, but
172-// OpenAI-compatible serialization can drop the original provider metadata.
173-func addGoogleOpenAICompatThoughtSignatures(payload map[string]any) bool {
174-messages, ok := payload["messages"].([]any)
175-if !ok {
176-return false
177- }
178-179-currentTurnStart := -1
180-for i, raw := range messages {
181-message, ok := raw.(map[string]any)
182-if !ok {
183-continue
184- }
185-if role, _ := message["role"].(string); role == "user" {
186-currentTurnStart = i
187- }
188- }
189-190-if currentTurnStart == -1 {
191-return false
192- }
193-194-changed := false
195-for _, raw := range messages[currentTurnStart+1:] {
196-message, ok := raw.(map[string]any)
197-if !ok || !isOpenAICompatAssistantRole(message["role"]) {
198-continue
199- }
200-toolCalls, ok := message["tool_calls"].([]any)
201-if !ok || len(toolCalls) == 0 {
202-continue
203- }
204-firstToolCall, ok := toolCalls[0].(map[string]any)
205-if !ok {
206-continue
207- }
208-if ensureGoogleOpenAICompatThoughtSignature(firstToolCall) {
209-changed = true
210- }
211- }
212-return changed
213-}
214-215-func isOpenAICompatAssistantRole(role any) bool {
216-roleValue, _ := role.(string)
217-return roleValue == "assistant" || roleValue == "model"
218-}
219-220-func ensureGoogleOpenAICompatThoughtSignature(toolCall map[string]any) bool {
221-extraContent, _ := toolCall["extra_content"].(map[string]any)
222-google, _ := extraContent["google"].(map[string]any)
223-if signature, _ := google["thought_signature"].(string); signature != "" {
224-return false
225- }
226-if extraContent == nil {
227-extraContent = map[string]any{}
228-toolCall["extra_content"] = extraContent
229- }
230-if google == nil {
231-google = map[string]any{}
232-extraContent["google"] = google
233- }
234-google["thought_signature"] = googleOpenAICompatDummyThoughtSignature
235-return true
236-}