fix: preserve gemini thought signatures (#25933)

AI Bridge reserializes OpenAI chat-completions requests before sending
them upstream. For Gemini OpenAI-compatible routes, that OpenAI
typed-parameter round trip drops
`tool_calls[].extra_content.google.thought_signature`, so Google rejects
tool-result continuations with `Function call is missing a
thought_signature`.

This PR:
- patches the AI Bridge upstream serialization boundary for Gemini
OpenAI-compatible chat completions
- shares the Gemini thought-signature patching helpers with chatd's
OpenAI-compatible transport patch to keep behavior consistent
- treats direct Google OpenAI-compatible upstream endpoints as
Gemini-scoped even when the request model is an alias
- adds the Google fallback thought signature to every assistant tool
call in the active turn, including parallel tool calls
- covers the regression that `extra_content` is dropped before the
upstream body is patched

> Mux updated this PR description on behalf of Mike.

---------

Co-authored-by: Susana Cardoso Ferreira <susana@coder.com>
This commit is contained in:
Michael Suchacz
2026-06-09 12:11:43 +01:00
committed by GitHub
co-authored by Susana Cardoso Ferreira
parent a9fb2619e4
commit c349ea6b78
8 changed files with 488 additions and 105 deletions
@@ -291,7 +291,15 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc
_, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...))
defer tracing.EndSpanErr(span, &outErr)
return svc.New(ctx, i.req.ChatCompletionNewParams, opts...)
requestOpts, overrideBody, err := i.chatCompletionRequestOptions(opts)
if err != nil {
return nil, xerrors.Errorf("prepare request body: %w", err)
}
params := i.req.ChatCompletionNewParams
if overrideBody {
params = openai.ChatCompletionNewParams{}
}
return svc.New(ctx, params, requestOpts...)
}
// newChatCompletionWithKeyFailover walks the centralized key pool, trying each
@@ -0,0 +1,37 @@
package chatcompletions
import (
"encoding/json"
"slices"
"github.com/openai/openai-go/v3/option"
"github.com/coder/coder/v2/internal/googleopenai"
)
func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) {
body, err := json.Marshal(i.req.ChatCompletionNewParams)
if err != nil {
return nil, err
}
if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) {
return body, nil
}
patched, _, err := googleopenai.PatchThoughtSignatures(body)
if err != nil {
return nil, err
}
return patched, nil
}
func (i *interceptionBase) chatCompletionRequestOptions(opts []option.RequestOption) ([]option.RequestOption, bool, error) {
if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) {
return opts, false, nil
}
body, err := i.chatCompletionRequestBody()
if err != nil {
return nil, false, err
}
updated := slices.Clone(opts)
return append(updated, option.WithRequestBody("application/json", body)), true, nil
}
@@ -0,0 +1,100 @@
package chatcompletions
import (
"encoding/json"
"testing"
"github.com/openai/openai-go/v3/option"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/internal/googleopenai"
)
func TestGoogleOpenAICompatThoughtSignaturePatchSurvivesParamRoundTrip(t *testing.T) {
t.Parallel()
const originalSignature = "SIG123"
raw := []byte(`{
"model":"gemini-3.5-flash",
"stream":true,
"messages":[
{"role":"user","content":"write a file"},
{
"role":"assistant",
"content":"I'll search for available workspace templates.",
"tool_calls":[
{
"id":"pbk491lp",
"function":{"arguments":"{}","name":"list_templates"},
"type":"function",
"extra_content":{"google":{"thought_signature":"` + originalSignature + `"}}
}
]
},
{"role":"tool","tool_call_id":"pbk491lp","content":"{}"}
]
}`)
var req ChatCompletionNewParamsWrapper
require.NoError(t, json.Unmarshal(raw, &req))
roundTripped, err := json.Marshal(req.ChatCompletionNewParams)
require.NoError(t, err)
require.Empty(t, googleThoughtSignatureFromBody(t, roundTripped, 1, 0),
"openai-go drops extra_content during the typed param round-trip")
body, err := (&interceptionBase{
req: &req,
cfg: config.OpenAI{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"},
}).chatCompletionRequestBody()
require.NoError(t, err)
require.Equal(t, googleopenai.DummyThoughtSignature, googleThoughtSignatureFromBody(t, body, 1, 0))
}
func TestGoogleOpenAICompatChatCompletionRequestOptions(t *testing.T) {
t.Parallel()
var req ChatCompletionNewParamsWrapper
require.NoError(t, json.Unmarshal([]byte(`{
"model":"gemini-3.5-flash",
"messages":[
{"role":"user","content":"current turn"},
{
"role":"assistant",
"tool_calls":[{"id":"call-1","function":{"arguments":"{}","name":"list_templates"},"type":"function"}]
}
]
}`), &req))
opts := make([]option.RequestOption, 1)
updated, overrideBody, err := (&interceptionBase{
req: &req,
cfg: config.OpenAI{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"},
}).chatCompletionRequestOptions(opts)
require.NoError(t, err)
require.True(t, overrideBody)
require.Len(t, opts, 1)
require.Len(t, updated, 2)
}
func googleThoughtSignatureFromBody(t *testing.T, body []byte, messageIndex int, toolCallIndex int) string {
t.Helper()
var payload map[string]any
require.NoError(t, json.Unmarshal(body, &payload))
messages, ok := payload["messages"].([]any)
require.True(t, ok)
require.Greater(t, len(messages), messageIndex)
message, ok := messages[messageIndex].(map[string]any)
require.True(t, ok)
toolCalls, ok := message["tool_calls"].([]any)
require.True(t, ok)
require.Greater(t, len(toolCalls), toolCallIndex)
toolCall, ok := toolCalls[toolCallIndex].(map[string]any)
require.True(t, ok)
extraContent, _ := toolCall["extra_content"].(map[string]any)
google, _ := extraContent["google"].(map[string]any)
signature, _ := google["thought_signature"].(string)
return signature
}
@@ -193,7 +193,9 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re
// We take control of request body here and pass it to the SDK as a raw byte slice.
// This is because the SDK's serialization applies hidden request options that result in
// unexpected, breaking behavior. See https://github.com/coder/aibridge/pull/164
body, err := json.Marshal(i.req.ChatCompletionNewParams)
// chatCompletionRequestBody also applies provider-specific
// compatibility patches to the exact body sent upstream.
body, err := i.chatCompletionRequestBody()
if err != nil {
return xerrors.Errorf("marshal request body: %w", err)
}