diff --git a/go-api/internal/gateway/gateway.go b/go-api/internal/gateway/gateway.go index 9366a3b..c5d4398 100644 --- a/go-api/internal/gateway/gateway.go +++ b/go-api/internal/gateway/gateway.go @@ -85,6 +85,25 @@ type ToolCall struct { ID string Name string 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. diff --git a/go-api/internal/gateway/openai.go b/go-api/internal/gateway/openai.go index 2df7e32..a8211b9 100644 --- a/go-api/internal/gateway/openai.go +++ b/go-api/internal/gateway/openai.go @@ -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 } diff --git a/go-api/internal/gateway/openai_stream_test.go b/go-api/internal/gateway/openai_stream_test.go index ed36bdb..9ad26d8 100644 --- a/go-api/internal/gateway/openai_stream_test.go +++ b/go-api/internal/gateway/openai_stream_test.go @@ -209,3 +209,29 @@ func TestStreamCompleteUsesTheStreamingPath(t *testing.T) { 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) + } +} diff --git a/go-api/internal/gateway/openai_test.go b/go-api/internal/gateway/openai_test.go index 9dc7749..7cc63fe 100644 --- a/go-api/internal/gateway/openai_test.go +++ b/go-api/internal/gateway/openai_test.go @@ -339,3 +339,96 @@ func TestBaseURLDefaultsAndTrimsSlash(t *testing.T) { 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) + } + } +}