Round-trip provider metadata on tool calls; Gemini requires it
Gemini 3 models attach a thought signature to every function call and reject the follow-up -- 400, "Function call is missing a thought_signature in functionCall parts" -- when the assistant message echoing that call does not carry it back. The gateway rebuilt the assistant turn from id, name and arguments alone, so every tool-using run on Gemini died on its second model call, after a first call that looked perfectly healthy. Found when production was pointed at Gemini on 2026-09-22; rolled back to Groq within minutes. ToolCall gains an opaque Extra field: the raw JSON of the wire's extra_content, captured on both the streaming and non-streaming paths and emitted verbatim on the next request. The gateway does not read it and must not -- the point of one wire shape is that a vendor's private fields pass through untouched. Absent stays absent; no provider receives a null it never sent. Also: Gemini wraps its error body in a one-element array, which the message parser read as "no detail". The trajectory therefore said only "the model rejected the request" where the body named the missing signature outright. Unwrapped now, so the next provider quirk is legible in the trajectory instead of costing a day of proxy captures. Verified end to end with the real gateway against real Gemini: a three-turn tool-calling run completed and the proxy confirmed the signature on every echoed call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
@@ -85,6 +85,25 @@ type ToolCall struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Input json.RawMessage
|
Input json.RawMessage
|
||||||
|
|
||||||
|
// Extra is provider metadata attached to the call, carried back to the
|
||||||
|
// provider verbatim on the next turn and never read here.
|
||||||
|
//
|
||||||
|
// It exists because at least one provider requires it. Gemini 3 models
|
||||||
|
// attach a "thought signature" to every function call and REJECT the
|
||||||
|
// follow-up request — 400, "Function call is missing a thought_signature"
|
||||||
|
// — if the assistant message that echoes the call does not carry it back.
|
||||||
|
// A gateway that rebuilds the assistant turn from ID, Name and Input alone
|
||||||
|
// drops it, and every tool-using run dies on its second model call while
|
||||||
|
// the first one looked perfectly healthy. That is exactly what happened
|
||||||
|
// on 2026-09-22 when production was pointed at Gemini.
|
||||||
|
//
|
||||||
|
// The gateway does not know what is in it and must not: the whole point
|
||||||
|
// of speaking one wire shape is that a vendor's private fields pass
|
||||||
|
// through untouched. It is the raw JSON of the call's extra_content
|
||||||
|
// object, or nil when the provider sent none, in which case it is omitted
|
||||||
|
// from the request again.
|
||||||
|
Extra json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolResult is what came back, on its way to the model.
|
// ToolResult is what came back, on its way to the model.
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ func (g *OpenAIGateway) decode(
|
|||||||
// The raw JSON, not a parsed value — handed to the handler's own
|
// The raw JSON, not a parsed value — handed to the handler's own
|
||||||
// decoder rather than matched on as a string here.
|
// decoder rather than matched on as a string here.
|
||||||
Input: json.RawMessage(args),
|
Input: json.RawMessage(args),
|
||||||
|
Extra: c.ExtraContent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +302,10 @@ type oaiToolCall struct {
|
|||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Function oaiFunctionRef `json:"function"`
|
Function oaiFunctionRef `json:"function"`
|
||||||
|
|
||||||
|
// ExtraContent is the provider's own metadata on the call, round-tripped
|
||||||
|
// as raw JSON. See ToolCall.Extra for why it is not optional.
|
||||||
|
ExtraContent json.RawMessage `json:"extra_content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type oaiFunctionRef struct {
|
type oaiFunctionRef struct {
|
||||||
@@ -493,9 +498,10 @@ func encodeOpenAIMessages(system string, msgs []Message) []oaiMessage {
|
|||||||
args = "{}"
|
args = "{}"
|
||||||
}
|
}
|
||||||
msg.ToolCalls = append(msg.ToolCalls, oaiToolCall{
|
msg.ToolCalls = append(msg.ToolCalls, oaiToolCall{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Type: "function",
|
Type: "function",
|
||||||
Function: oaiFunctionRef{Name: c.Name, Arguments: args},
|
Function: oaiFunctionRef{Name: c.Name, Arguments: args},
|
||||||
|
ExtraContent: c.Extra,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
out = append(out, msg)
|
out = append(out, msg)
|
||||||
@@ -549,6 +555,20 @@ func translateOpenAI(status int, body []byte) error {
|
|||||||
// worth reading and not often enough to depend on, so an unparseable body
|
// worth reading and not often enough to depend on, so an unparseable body
|
||||||
// yields nothing rather than failing a failure.
|
// yields nothing rather than failing a failure.
|
||||||
func openAIErrorMessage(body []byte) string {
|
func openAIErrorMessage(body []byte) string {
|
||||||
|
// Gemini wraps its error in a one-element ARRAY — `[{"error":{...}}]` —
|
||||||
|
// where OpenAI, Groq and the rest send the object bare. Unwrapped here
|
||||||
|
// rather than tolerated as "no detail", because the detail is the whole
|
||||||
|
// value of the field: for two weeks the trajectory said only "the model
|
||||||
|
// rejected the request" when the body said "Function call is missing a
|
||||||
|
// thought_signature", and the difference was a day of diagnosis.
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
if bytes.HasPrefix(body, []byte("[")) {
|
||||||
|
var many []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &many); err != nil || len(many) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
body = many[0]
|
||||||
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Error struct {
|
Error struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -744,6 +764,11 @@ func (a *streamAccumulator) addToolCallDeltas(deltas []oaiToolCall) {
|
|||||||
if d.Function.Name != "" {
|
if d.Function.Name != "" {
|
||||||
call.Function.Name = d.Function.Name
|
call.Function.Name = d.Function.Name
|
||||||
}
|
}
|
||||||
|
// Provider metadata arrives whole on one fragment, like the id. Kept
|
||||||
|
// when non-empty so a later empty fragment does not erase it.
|
||||||
|
if len(d.ExtraContent) > 0 {
|
||||||
|
call.ExtraContent = d.ExtraContent
|
||||||
|
}
|
||||||
// Arguments are the fragmented field: concatenated, never replaced.
|
// Arguments are the fragmented field: concatenated, never replaced.
|
||||||
call.Function.Arguments += d.Function.Arguments
|
call.Function.Arguments += d.Function.Arguments
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,3 +209,29 @@ func TestStreamCompleteUsesTheStreamingPath(t *testing.T) {
|
|||||||
t.Errorf("Text = %q", resp.Text)
|
t.Errorf("Text = %q", resp.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The streamed shape of TestToolCallProviderMetadataIsRoundTripped: the
|
||||||
|
// metadata arrives on one fragment, and later fragments that carry only
|
||||||
|
// argument text must not erase it.
|
||||||
|
func TestStreamKeepsToolCallProviderMetadata(t *testing.T) {
|
||||||
|
const sig = `{"google":{"thought_signature":"El4KXAFpFH0T4CM3"}}`
|
||||||
|
acc, err := accumulateSSE(strings.NewReader(strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"open_positions","arguments":""},"extra_content":` + sig + `}]}}]}`,
|
||||||
|
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
|
||||||
|
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
}, "\n\n")), func(string) {})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accumulateSSE: %v", err)
|
||||||
|
}
|
||||||
|
msg := acc.message()
|
||||||
|
if len(msg.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("got %d tool calls, want 1", len(msg.ToolCalls))
|
||||||
|
}
|
||||||
|
if string(msg.ToolCalls[0].ExtraContent) != sig {
|
||||||
|
t.Errorf("extra_content after streaming = %s, want %s", msg.ToolCalls[0].ExtraContent, sig)
|
||||||
|
}
|
||||||
|
if msg.ToolCalls[0].Function.Arguments != "{}" {
|
||||||
|
t.Errorf("arguments = %q, want {}", msg.ToolCalls[0].Function.Arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -339,3 +339,96 @@ func TestBaseURLDefaultsAndTrimsSlash(t *testing.T) {
|
|||||||
t.Errorf("endpoint = %q, want the trailing slash collapsed", got)
|
t.Errorf("endpoint = %q, want the trailing slash collapsed", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THE FAILURE THIS EXISTS FOR: a provider that attaches private metadata to a
|
||||||
|
// tool call and refuses the follow-up without it. Gemini 3 does exactly this
|
||||||
|
// ("Function call is missing a thought_signature"), and a gateway that rebuilt
|
||||||
|
// the assistant turn from id, name and arguments alone killed every tool-using
|
||||||
|
// run on its second model call — after a first call that looked healthy.
|
||||||
|
//
|
||||||
|
// The round trip is tested end to end: the provider's extra_content on the
|
||||||
|
// response must reappear, byte for byte, on the next request's echo of that
|
||||||
|
// call. The gateway must not care what is inside it.
|
||||||
|
func TestToolCallProviderMetadataIsRoundTripped(t *testing.T) {
|
||||||
|
const sig = `{"google":{"thought_signature":"El4KXAFpFH0T4CM3"}}`
|
||||||
|
|
||||||
|
gw, captured := serve(t, func(w http.ResponseWriter, _ *oaiRequest) {
|
||||||
|
_, _ = io.WriteString(w, `{
|
||||||
|
"choices":[{"message":{"role":"assistant","tool_calls":[
|
||||||
|
{"id":"call_x","type":"function",
|
||||||
|
"function":{"name":"open_positions","arguments":"{}"},
|
||||||
|
"extra_content":`+sig+`}]},
|
||||||
|
"finish_reason":"tool_calls"}],
|
||||||
|
"usage":{"prompt_tokens":10,"completion_tokens":5}
|
||||||
|
}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := gw.Complete(context.Background(), ask("how many open positions?"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("got %d tool calls, want 1", len(resp.ToolCalls))
|
||||||
|
}
|
||||||
|
if string(resp.ToolCalls[0].Extra) != sig {
|
||||||
|
t.Fatalf("Extra = %s, want the provider's extra_content verbatim", resp.ToolCalls[0].Extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second turn: the loop echoes the assistant's call and adds the result.
|
||||||
|
// This is the request Gemini rejects when the signature is missing.
|
||||||
|
_, err = gw.Complete(context.Background(), Request{Tier: TierBalanced, Messages: []Message{
|
||||||
|
{Role: RoleUser, Text: "how many open positions?"},
|
||||||
|
{Role: RoleAssistant, ToolCalls: resp.ToolCalls},
|
||||||
|
{Role: RoleUser, ToolResults: []ToolResult{{CallID: "call_x", Content: `{"count":14}`}}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Complete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var echoed *oaiToolCall
|
||||||
|
for i := range captured.Messages {
|
||||||
|
if len(captured.Messages[i].ToolCalls) > 0 {
|
||||||
|
echoed = &captured.Messages[i].ToolCalls[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if echoed == nil {
|
||||||
|
t.Fatalf("the second request did not echo the assistant's tool call: %+v", captured.Messages)
|
||||||
|
}
|
||||||
|
if string(echoed.ExtraContent) != sig {
|
||||||
|
t.Errorf("echoed extra_content = %s, want %s", echoed.ExtraContent, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider that sends no metadata must not receive an "extra_content": null
|
||||||
|
// it never asked for. Absent stays absent.
|
||||||
|
func TestToolCallWithoutProviderMetadataOmitsTheField(t *testing.T) {
|
||||||
|
msgs := []Message{
|
||||||
|
{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "call_1", Name: "open_positions", Input: json.RawMessage(`{}`)}}},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(encodeOpenAIMessages("", msgs))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "extra_content") {
|
||||||
|
t.Errorf("extra_content was emitted for a call that had none: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini wraps its error in a one-element array. The detail must survive,
|
||||||
|
// because a bare "the model rejected the request" is the difference between a
|
||||||
|
// one-line diagnosis and a day of one.
|
||||||
|
func TestProviderErrorDetailSurvivesArrayEnvelope(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
`{"error":{"message":"bare object"}}`: "bare object",
|
||||||
|
`[{"error":{"message":"array wrapped"}}]`: "array wrapped",
|
||||||
|
` [ {"error":{"message":"padded"}} ] `: "padded",
|
||||||
|
`{"message":"top level"}`: "top level",
|
||||||
|
`[]`: "",
|
||||||
|
`not json`: "",
|
||||||
|
}
|
||||||
|
for body, want := range cases {
|
||||||
|
if got := openAIErrorMessage([]byte(body)); got != want {
|
||||||
|
t.Errorf("openAIErrorMessage(%s) = %q, want %q", body, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user