diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 1f5fbac3..fb4589f5 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -695,10 +695,27 @@ func decodeOpenAIPresetSSE(body []byte) (normalizedStageOutput, error) { stage.Deltas = append(stage.Deltas, normalizedStageDelta{Kind: normalizedStageDeltaReasoning, Text: reasoning}) } for _, delta := range choice.Delta.ToolCalls { - state := tools[delta.Index] + toolIndex := delta.Index + if toolIndex == 0 && delta.ID != "" { + if first := tools[0]; first != nil && first.id != "" && first.id != delta.ID { + matched := false + for existingIndex, existing := range tools { + if existing.id == delta.ID { + toolIndex = existingIndex + matched = true + break + } + } + if !matched { + for toolIndex = 1; tools[toolIndex] != nil; toolIndex++ { + } + } + } + } + state := tools[toolIndex] if state == nil { state = &toolState{} - tools[delta.Index] = state + tools[toolIndex] = state } if delta.ID != "" { state.id = delta.ID diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 782ff586..b1693909 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -102,6 +102,20 @@ func TestGeminiChatProviderResponseAddsMissingParallelToolIndices(t *testing.T) } } +func TestOpenAIPresetSSESeparatesNewToolIDsWithoutIndices(t *testing.T) { + body := []byte("data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"plan.md\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-2\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"review.md\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-3\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"test -f index.html\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n") + stage, err := decodeOpenAIPresetSSE(body) + 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