mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Susana Cardoso Ferreira
parent
a9fb2619e4
commit
c349ea6b78
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user