mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-06 07:39:50 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user