fix(claude): synthesize belated tool_use starts for empty-name calls

- Emit delayed `content_block_start` for tool calls that arrive with missing names but still carry `id`/arguments, using fallback `tool_<index>` names.
- Keep dropping only fully empty signals (no `id`, no `name`, no `arguments`) so legitimate streams are preserved and `tool_use` stop-reason behavior remains correct.

Closes: #3748
This commit is contained in:
Luis Pater
2026-08-08 03:45:50 +08:00
parent 31a4e9b487
commit 37609fa179
2 changed files with 117 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ package claude
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
@@ -304,14 +305,8 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
if !param.ContentBlocksStopped {
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
if !accumulator.StartEmitted {
// Belated emit for streams that supplied a valid name but
// never sent an id. SanitizeClaudeToolID("") produces the
// expected stable synthetic toolu_<nanos>_<n> ID shape.
if accumulator.Name == "" {
continue
}
emitToolUseStart(param, index, accumulator, &results)
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
continue
}
blockIndex := param.toolContentBlockIndex(index)
@@ -377,13 +372,8 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
if !param.ContentBlocksStopped {
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
if !accumulator.StartEmitted {
// Belated emit at [DONE]; same behavior as the finish_reason
// path for name-but-no-id streams.
if accumulator.Name == "" {
continue
}
emitToolUseStart(param, index, accumulator, &results)
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
continue
}
blockIndex := param.toolContentBlockIndex(index)
@@ -591,6 +581,29 @@ func emitToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolI
param.SawToolCall = true
}
// emitBelatedToolUseStart finalizes a tool_use block that never received a
// mid-stream start. Some OpenAI-compatible providers leave function.name empty
// for the whole stream; dropping those calls loses tool_use for Claude Code and
// can trigger retry loops. When name is still empty but the call has an id
// and/or arguments, synthesize tool_<index> instead of silently discarding it.
// Returns false when the accumulator has no usable tool-call signal.
func emitBelatedToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) bool {
if accumulator == nil {
return false
}
if accumulator.StartEmitted {
return true
}
if accumulator.Name == "" && accumulator.ID == "" && accumulator.Arguments.Len() == 0 {
return false
}
if accumulator.Name == "" {
accumulator.Name = fmt.Sprintf("tool_%d", openAIToolIndex)
}
emitToolUseStart(param, openAIToolIndex, accumulator, results)
return true
}
func toolCallAccumulatorIndexes(accumulators map[int]*ToolCallAccumulator) []int {
indexes := make([]int, 0, len(accumulators))
for index := range accumulators {

View File

@@ -163,17 +163,24 @@ func TestStreamingTool_EmptyNameThroughout(t *testing.T) {
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := len(toolUseStarts(events)); got != 0 {
t.Fatalf("expected zero tool_use content_block_start, got %d (events=%+v)", got, events)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one tool_use content_block_start with synthetic name, got %d (events=%+v)", len(starts), events)
}
if got := countByType(events, "content_block_delta"); got != 0 {
t.Fatalf("expected zero content_block_delta when start was suppressed, got %d", got)
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
if got := countByType(events, "content_block_stop"); got != 0 {
t.Fatalf("expected zero content_block_stop when start was suppressed, got %d", got)
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := lastStopReason(events); got == "tool_use" {
t.Fatalf("stop_reason must not be tool_use when zero tool_use blocks were emitted; got %q", got)
if got := countByType(events, "content_block_delta"); got != 1 {
t.Fatalf("expected one content_block_delta for accumulated args, got %d", got)
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected one content_block_stop, got %d", got)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
@@ -182,11 +189,21 @@ func TestStreamingTool_NullName(t *testing.T) {
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":null,"arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := len(toolUseStarts(events)); got != 0 {
t.Fatalf("null name must not produce a tool_use start; got %d", got)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("null name with id should belated-emit synthetic tool name; got %d", len(starts))
}
if got := countByType(events, "content_block_stop"); got != 0 {
t.Fatalf("null name must not produce content_block_stop; got %d", got)
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected one content_block_stop, got %d", got)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
@@ -195,8 +212,12 @@ func TestStreamingTool_NonStringName(t *testing.T) {
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":123,"arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := len(toolUseStarts(events)); got != 0 {
t.Fatalf("non-string name must not produce a tool_use start; got %d", got)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("non-string name with id should belated-emit synthetic tool name; got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
}
@@ -220,10 +241,10 @@ func TestStreamingTool_RepeatedName(t *testing.T) {
}
}
func TestStreamingTool_MixedSuppressedAndValid(t *testing.T) {
func TestStreamingTool_MixedEmptyNameAndValid(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
{"index":0,"id":"call_skip","function":{"name":"","arguments":""}},
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":""}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[
@@ -233,16 +254,36 @@ func TestStreamingTool_MixedSuppressedAndValid(t *testing.T) {
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
if len(starts) != 2 {
t.Fatalf("expected two tool_use starts (valid mid-stream + synthetic empty-name), got %d", len(starts))
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected exactly one content_block_stop, got %d", got)
// Valid name+id is emitted mid-stream first; empty-name is belated at finish.
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("first tool name = %q, want %q", name, "do_it")
}
if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("second tool name = %q, want %q", name, "tool_0")
}
if got := countByType(events, "content_block_stop"); got != 2 {
t.Fatalf("expected two content_block_stop events, got %d", got)
}
indices := blockIndices(events)
if len(indices) == 0 || indices[0] != 0 {
t.Fatalf("first content_block_start index must be 0, got %v", indices)
if len(indices) < 2 || indices[0] != 0 || indices[1] != 1 {
t.Fatalf("content_block_start indices must be [0,1], got %v", indices)
}
}
func TestStreamingTool_EmptyNameWithoutSignalIsSuppressed(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := len(toolUseStarts(events)); got != 0 {
t.Fatalf("empty name without id/args must stay suppressed; got %d", got)
}
if got := lastStopReason(events); got == "tool_use" {
t.Fatalf("stop_reason must not be tool_use when zero tool_use blocks were emitted; got %q", got)
}
}
@@ -371,10 +412,10 @@ func TestStreamingTool_LateIDAfterFinalization(t *testing.T) {
}
}
func TestStreamingTool_StopReasonMixedSuppressedAndValid(t *testing.T) {
func TestStreamingTool_StopReasonMixedEmptyNameAndValid(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
{"index":0,"id":"call_skip","function":{"name":"","arguments":""}},
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":"{}"}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
@@ -382,4 +423,28 @@ func TestStreamingTool_StopReasonMixedSuppressedAndValid(t *testing.T) {
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
if got := len(toolUseStarts(events)); got != 2 {
t.Fatalf("expected two tool_use starts, got %d", got)
}
}
func TestStreamingTool_EmptyNameArgsOnlyNoID(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"q\":\"x\"}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start for empty-name args-only call, got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
id := gjson.Get(starts[0].Payload, "content_block.id").String()
if !strings.HasPrefix(id, "toolu_") {
t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}