diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 05803ea5..0fc9a883 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -510,9 +510,6 @@ func rewriteProviderJSONResponse(body []byte, model, toolCallWire string) []byte } func normalizeGeminiChatProviderResponse(body []byte) []byte { - if !bytes.Contains(body, []byte(`"thought_signature"`)) { - return body - } decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() var response map[string]any @@ -530,11 +527,22 @@ func normalizeGeminiChatProviderResponse(body []byte) []byte { if !ok { continue } - for _, rawCall := range anySlice(message["tool_calls"]) { + toolCalls := anySlice(message["tool_calls"]) + for index, rawCall := range toolCalls { call, ok := rawCall.(map[string]any) if !ok { continue } + // Gemini's Chat-compatible stream may emit several complete tool + // calls in one delta without OpenAI's per-call index. Preserve their + // positional identity so the stream decoder does not concatenate + // independent argument objects into index zero. + if messageKey == "delta" && len(toolCalls) > 1 { + if _, present := call["index"]; !present { + call["index"] = index + changed = true + } + } id, idOK := call["id"].(string) extra, extraOK := call["extra_content"].(map[string]any) if !idOK || id == "" || !extraOK { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index f93d899e..782ff586 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -76,6 +76,32 @@ func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T) } } +func TestGeminiChatProviderResponseAddsMissingParallelToolIndices(t *testing.T) { + response := []byte(`{"id":"chat-1","choices":[{"delta":{"tool_calls":[{"id":"call-1","function":{"name":"read_file","arguments":"{\"path\":\"plan.md\"}"}},{"id":"call-2","function":{"name":"read_file","arguments":"{\"path\":\"review.md\"}"}},{"id":"call-3","function":{"name":"bash","arguments":"{\"command\":\"test -f index.html\"}"}}]}}]}`) + normalized := normalizeGeminiChatProviderResponse(response) + var body map[string]any + if err := json.Unmarshal(normalized, &body); err != nil { + t.Fatal(err) + } + choice := anySlice(body["choices"])[0].(map[string]any) + delta := choice["delta"].(map[string]any) + calls := anySlice(delta["tool_calls"]) + for index, raw := range calls { + call := raw.(map[string]any) + if got := int(call["index"].(float64)); got != index { + t.Fatalf("tool call %d index = %d", index, got) + } + } + + stage, err := decodeOpenAIPresetSSE([]byte("data: " + string(normalized) + "\n\ndata: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n")) + if err != nil { + t.Fatal(err) + } + if len(stage.ToolCalls) != 3 { + t.Fatalf("decoded tool calls = %+v", stage.ToolCalls) + } +} + func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) { tests := []struct { name string