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:
2026-09-22 13:15:39 +05:30
parent 5166fde764
commit 797ee5f2d2
4 changed files with 166 additions and 3 deletions

View File

@@ -247,6 +247,7 @@ func (g *OpenAIGateway) decode(
// The raw JSON, not a parsed value — handed to the handler's own
// decoder rather than matched on as a string here.
Input: json.RawMessage(args),
Extra: c.ExtraContent,
})
}
@@ -301,6 +302,10 @@ type oaiToolCall struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
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 {
@@ -493,9 +498,10 @@ func encodeOpenAIMessages(system string, msgs []Message) []oaiMessage {
args = "{}"
}
msg.ToolCalls = append(msg.ToolCalls, oaiToolCall{
ID: c.ID,
Type: "function",
Function: oaiFunctionRef{Name: c.Name, Arguments: args},
ID: c.ID,
Type: "function",
Function: oaiFunctionRef{Name: c.Name, Arguments: args},
ExtraContent: c.Extra,
})
}
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
// yields nothing rather than failing a failure.
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 {
Error struct {
Message string `json:"message"`
@@ -744,6 +764,11 @@ func (a *streamAccumulator) addToolCallDeltas(deltas []oaiToolCall) {
if 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.
call.Function.Arguments += d.Function.Arguments
}