History

fix(openai-claude): stabilize streaming tool_use blocks

slicenfer committed 6 months ago

Commit 1c2153a1 parent 605adaa2 files · +403 −25Browse files

Unified · Split

2 files
  1. internal/translator/openai/claude/openai_claude_response.go +77 −24
  2. internal/translator/openai/claude/openai_claude_response_test.go +326 −1
internal/translator/openai/claude/openai_claude_response.go+77−24
@@ -8,6 +8,7 @@ package claude · 7 unmodified lines
88 import (
99 "bytes"
1010 "context"
11+ "sort"
1112 "strings"
1213
1314 translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
@@ -26,6 +27,9 @@ type ConvertOpenAIResponseToAnthropicParams struct { · 12 unmodified lines
2627 Model string
2728 CreatedAt int64
2829 ToolNameMap map[string]string
30+ // SawToolCall is true once at least one tool_use content_block_start has
31+ // been emitted on the wire. Using raw upstream tool_calls presence here
32+ // can produce stop_reason=tool_use with zero announced tool blocks.
2933 SawToolCall bool
3034 // Content accumulator for streaming
3135 ContentAccumulator strings.Builder
@@ -60,6 +64,9 @@ type ToolCallAccumulator struct { · 28 unmodified lines
6064 ID string
6165 Name string
6266 Arguments strings.Builder
67+ // StartEmitted tracks whether content_block_start has already been sent
68+ // for this tool index.
69+ StartEmitted bool
6370 }
6471
6572 // ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.
@@ -218,9 +225,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 152 unmodified lines
218225 }
219226
220227 toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
221− param.SawToolCall = true
222228 index := int(toolCall.Get("index").Int())
223− blockIndex := param.toolContentBlockIndex(index)
224229
225230 // Initialize accumulator if needed
226231 if _, exists := param.ToolCallsAccumulator[index]; !exists {
@@ -229,27 +234,25 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 2 unmodified lines
229234
230235 accumulator := param.ToolCallsAccumulator[index]
231236
232− // Handle tool call ID
233− if id := toolCall.Get("id"); id.Exists() {
234− accumulator.ID = id.String()
237+ // Handle tool call ID. Only accept JSON-string, non-empty
238+ // values so malformed upstream fields do not overwrite a
239+ // valid ID or coerce into a content_block.id.
240+ if id := toolCall.Get("id"); id.Exists() && id.Type == gjson.String {
241+ if idStr := id.String(); idStr != "" {
242+ accumulator.ID = idStr
243+ }
235244 }
236245
237− // Handle function name
246+ // Handle function name and arguments
238247 if function := toolCall.Get("function"); function.Exists() {
239− if name := function.Get("name"); name.Exists() && name.String() != "" {
240− accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
241−
242− stopThinkingContentBlock(param, &results)
243−
244− stopTextContentBlock(param, &results)
245−
246− // Send content_block_start for tool_use
247− contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
248− contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
249− contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", blockIndex)
250− contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
251− contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "content_block.name", accumulator.Name)
252− results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
248+ // Only record the name until content_block_start has been
249+ // emitted. Some upstreams send "name": "" or repeat the
250+ // field across chunks; reassigning after start could drift
251+ // from what was already announced.
252+ if !accumulator.StartEmitted {
253+ if name := function.Get("name"); name.Exists() && name.Type == gjson.String && name.String() != "" {
254+ accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
255+ }
253256 }
254257
255258 // Handle function arguments
@@ -261,6 +264,13 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 5 unmodified lines
261264 }
262265 }
263266
267+ // Re-check on every chunk, not only chunks with a function
268+ // object. Some upstreams split function.name and id across
269+ // separate deltas.
270+ if !accumulator.StartEmitted && accumulator.Name != "" && accumulator.ID != "" && !param.ContentBlocksStopped {
271+ emitToolUseStart(param, index, accumulator, &results)
272+ }
273+
264274 return true
265275 })
266276 }
@@ -269,9 +279,12 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 2 unmodified lines
269279 // Handle finish_reason (but don't send message_delta/message_stop yet)
270280 if finishReason := root.Get("choices.0.finish_reason"); finishReason.Exists() && finishReason.String() != "" {
271281 reason := finishReason.String()
272− if param.SawToolCall {
282+ switch {
283+ case param.SawToolCall:
273284 param.FinishReason = "tool_calls"
274− } else {
285+ case reason == "tool_calls":
286+ param.FinishReason = "stop"
287+ default:
275288 param.FinishReason = reason
276289 }
277290
@@ -289,8 +302,17 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 11 unmodified lines
289302
290303 // Send content_block_stop for any tool calls
291304 if !param.ContentBlocksStopped {
292− for index := range param.ToolCallsAccumulator {
305+ for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
293306 accumulator := param.ToolCallsAccumulator[index]
307+ if !accumulator.StartEmitted {
308+ // Belated emit for streams that supplied a valid name but
309+ // never sent an id. SanitizeClaudeToolID("") produces the
310+ // expected stable synthetic toolu_<nanos>_<n> ID shape.
311+ if accumulator.Name == "" {
312+ continue
313+ }
314+ emitToolUseStart(param, index, accumulator, &results)
315+ }
294316 blockIndex := param.toolContentBlockIndex(index)
295317
296318 // Send complete input_json_delta with all accumulated arguments
@@ -353,8 +375,16 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) · 56 unmodified lines
353375 stopTextContentBlock(param, &results)
354376
355377 if !param.ContentBlocksStopped {
356− for index := range param.ToolCallsAccumulator {
378+ for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
357379 accumulator := param.ToolCallsAccumulator[index]
380+ if !accumulator.StartEmitted {
381+ // Belated emit at [DONE]; same behavior as the finish_reason
382+ // path for name-but-no-id streams.
383+ if accumulator.Name == "" {
384+ continue
385+ }
386+ emitToolUseStart(param, index, accumulator, &results)
387+ }
358388 blockIndex := param.toolContentBlockIndex(index)
359389
360390 if accumulator.Arguments.Len() > 0 {
@@ -547,6 +577,29 @@ func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results · 186 unmodified lines
547577 param.TextContentBlockIndex = -1
548578 }
549579
580+func emitToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) {
581+ stopThinkingContentBlock(param, results)
582+ stopTextContentBlock(param, results)
583+
584+ blockIndex := param.toolContentBlockIndex(openAIToolIndex)
585+ contentBlockStartJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
586+ contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "index", blockIndex)
587+ contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
588+ contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.name", accumulator.Name)
589+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSON, 2))
590+ accumulator.StartEmitted = true
591+ param.SawToolCall = true
592+}
593+
594+func toolCallAccumulatorIndexes(accumulators map[int]*ToolCallAccumulator) []int {
595+ indexes := make([]int, 0, len(accumulators))
596+ for index := range accumulators {
597+ indexes = append(indexes, index)
598+ }
599+ sort.Ints(indexes)
600+ return indexes
601+}
602+
550603 // ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.
551604 //
552605 // Parameters:
internal/translator/openai/claude/openai_claude_response_test.go+326−1
@@ -3,11 +3,108 @@ package claude · 2 unmodified lines
33 import (
44 "bytes"
55 "context"
6+ "strings"
67 "testing"
8+
9+ "github.com/tidwall/gjson"
710 )
811
12+type sseEvent struct {
13+ Type string
14+ Payload string
15+}
16+
17+func runStream(t *testing.T, originalReq string, chunks ...string) []sseEvent {
18+ t.Helper()
19+
20+ var paramAny any
21+ var emitted [][]byte
22+ for _, chunk := range chunks {
23+ emitted = append(emitted, ConvertOpenAIResponseToClaude(
24+ context.Background(),
25+ "",
26+ []byte(originalReq),
27+ nil,
28+ []byte("data: "+chunk),
29+ &paramAny,
30+ )...)
31+ }
32+ emitted = append(emitted, ConvertOpenAIResponseToClaude(
33+ context.Background(),
34+ "",
35+ []byte(originalReq),
36+ nil,
37+ []byte("data: [DONE]"),
38+ &paramAny,
39+ )...)
40+
41+ var events []sseEvent
42+ for _, raw := range emitted {
43+ s := string(raw)
44+ if !strings.HasPrefix(s, "event: ") {
45+ continue
46+ }
47+ nl := strings.Index(s, "\n")
48+ if nl < 0 {
49+ continue
50+ }
51+ typ := strings.TrimPrefix(s[:nl], "event: ")
52+ rest := s[nl+1:]
53+ if !strings.HasPrefix(rest, "data: ") {
54+ continue
55+ }
56+ payload := strings.TrimRight(strings.TrimPrefix(rest, "data: "), "\n")
57+ events = append(events, sseEvent{Type: typ, Payload: payload})
58+ }
59+ return events
60+}
61+
62+func countByType(events []sseEvent, typ string) int {
63+ n := 0
64+ for _, e := range events {
65+ if e.Type == typ {
66+ n++
67+ }
68+ }
69+ return n
70+}
71+
72+func toolUseStarts(events []sseEvent) []sseEvent {
73+ var out []sseEvent
74+ for _, e := range events {
75+ if e.Type != "content_block_start" {
76+ continue
77+ }
78+ if gjson.Get(e.Payload, "content_block.type").String() == "tool_use" {
79+ out = append(out, e)
80+ }
81+ }
82+ return out
83+}
84+
85+func blockIndices(events []sseEvent) []int64 {
86+ var idx []int64
87+ for _, e := range events {
88+ if e.Type == "content_block_start" {
89+ idx = append(idx, gjson.Get(e.Payload, "index").Int())
90+ }
91+ }
92+ return idx
93+}
94+
95+func lastStopReason(events []sseEvent) string {
96+ for i := len(events) - 1; i >= 0; i-- {
97+ if events[i].Type == "message_delta" {
98+ return gjson.Get(events[i].Payload, "delta.stop_reason").String()
99+ }
100+ }
101+ return ""
102+}
103+
104+const streamReq = `{"stream":true}`
105+
9106 func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
10− originalRequest := []byte(`{"stream":true}`)
107+ originalRequest := []byte(streamReq)
11108 var param any
12109
13110 firstChunks := ConvertOpenAIResponseToClaude(
@@ -39,3 +136,231 @@ func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing · 25 unmodified lines
39136 t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
40137 }
41138 }
139+
140+func TestStreamingTool_EmptyNameThroughout(t *testing.T) {
141+ events := runStream(t, streamReq,
142+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"","arguments":""}}]}}]}`,
143+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"x\":1}"}}]}}]}`,
144+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
145+ )
146+
147+ if got := len(toolUseStarts(events)); got != 0 {
148+ t.Fatalf("expected zero tool_use content_block_start, got %d (events=%+v)", got, events)
149+ }
150+ if got := countByType(events, "content_block_delta"); got != 0 {
151+ t.Fatalf("expected zero content_block_delta when start was suppressed, got %d", got)
152+ }
153+ if got := countByType(events, "content_block_stop"); got != 0 {
154+ t.Fatalf("expected zero content_block_stop when start was suppressed, got %d", got)
155+ }
156+ if got := lastStopReason(events); got == "tool_use" {
157+ t.Fatalf("stop_reason must not be tool_use when zero tool_use blocks were emitted; got %q", got)
158+ }
159+}
160+
161+func TestStreamingTool_NullName(t *testing.T) {
162+ events := runStream(t, streamReq,
163+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":null,"arguments":""}}]}}]}`,
164+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
165+ )
166+ if got := len(toolUseStarts(events)); got != 0 {
167+ t.Fatalf("null name must not produce a tool_use start; got %d", got)
168+ }
169+ if got := countByType(events, "content_block_stop"); got != 0 {
170+ t.Fatalf("null name must not produce content_block_stop; got %d", got)
171+ }
172+}
173+
174+func TestStreamingTool_NonStringName(t *testing.T) {
175+ events := runStream(t, streamReq,
176+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":123,"arguments":""}}]}}]}`,
177+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
178+ )
179+ if got := len(toolUseStarts(events)); got != 0 {
180+ t.Fatalf("non-string name must not produce a tool_use start; got %d", got)
181+ }
182+}
183+
184+func TestStreamingTool_RepeatedName(t *testing.T) {
185+ events := runStream(t, streamReq,
186+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":""}}]}}]}`,
187+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":"{\"x\""}}]}}]}`,
188+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":":1}"}}]}}]}`,
189+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
190+ )
191+
192+ starts := toolUseStarts(events)
193+ if len(starts) != 1 {
194+ t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
195+ }
196+ if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
197+ t.Fatalf("announced tool name = %q, want %q", name, "do_it")
198+ }
199+ if got := countByType(events, "content_block_stop"); got != 1 {
200+ t.Fatalf("expected exactly one content_block_stop, got %d", got)
201+ }
202+}
203+
204+func TestStreamingTool_MixedSuppressedAndValid(t *testing.T) {
205+ events := runStream(t, streamReq,
206+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
207+ {"index":0,"id":"call_skip","function":{"name":"","arguments":""}},
208+ {"index":1,"id":"call_real","function":{"name":"do_it","arguments":""}}
209+ ]}}]}`,
210+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[
211+ {"index":1,"function":{"arguments":"{}"}}
212+ ]}}]}`,
213+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
214+ )
215+
216+ starts := toolUseStarts(events)
217+ if len(starts) != 1 {
218+ t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
219+ }
220+ if got := countByType(events, "content_block_stop"); got != 1 {
221+ t.Fatalf("expected exactly one content_block_stop, got %d", got)
222+ }
223+
224+ indices := blockIndices(events)
225+ if len(indices) == 0 || indices[0] != 0 {
226+ t.Fatalf("first content_block_start index must be 0, got %v", indices)
227+ }
228+}
229+
230+func TestStreamingTool_EmptyIDDeferStart(t *testing.T) {
231+ events := runStream(t, streamReq,
232+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"","function":{"name":"do_it","arguments":""}}]}}]}`,
233+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real","function":{"arguments":"{}"}}]}}]}`,
234+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
235+ )
236+
237+ starts := toolUseStarts(events)
238+ if len(starts) != 1 {
239+ t.Fatalf("expected exactly one tool_use start once id arrived, got %d", len(starts))
240+ }
241+ if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
242+ t.Fatalf("announced tool id = %q, want %q", id, "call_real")
243+ }
244+}
245+
246+func TestStreamingTool_IDInDeltaWithoutFunction(t *testing.T) {
247+ events := runStream(t, streamReq,
248+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
249+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real"}]}}]}`,
250+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
251+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
252+ )
253+
254+ starts := toolUseStarts(events)
255+ if len(starts) != 1 {
256+ t.Fatalf("expected exactly one tool_use start when id arrives in a function-less delta, got %d", len(starts))
257+ }
258+ if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
259+ t.Fatalf("announced tool id = %q, want %q", id, "call_real")
260+ }
261+ if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
262+ t.Fatalf("announced tool name = %q, want %q", name, "do_it")
263+ }
264+ if got := countByType(events, "content_block_stop"); got != 1 {
265+ t.Fatalf("expected exactly one content_block_stop, got %d", got)
266+ }
267+}
268+
269+func TestStreamingTool_StopReasonWithEmittedTool(t *testing.T) {
270+ events := runStream(t, streamReq,
271+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":"{}"}}]}}]}`,
272+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
273+ )
274+ if got := lastStopReason(events); got != "tool_use" {
275+ t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
276+ }
277+}
278+
279+func TestStreamingTool_StopReasonWhenIDNeverArrives(t *testing.T) {
280+ events := runStream(t, streamReq,
281+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it","arguments":""}}]}}]}`,
282+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
283+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
284+ )
285+
286+ starts := toolUseStarts(events)
287+ if len(starts) != 1 {
288+ t.Fatalf("expected one belated tool_use start with synthetic id, got %d", len(starts))
289+ }
290+ id := gjson.Get(starts[0].Payload, "content_block.id").String()
291+ if !strings.HasPrefix(id, "toolu_") {
292+ t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
293+ }
294+ if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
295+ t.Fatalf("announced tool name = %q, want %q", name, "do_it")
296+ }
297+ if got := lastStopReason(events); got != "tool_use" {
298+ t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
299+ }
300+}
301+
302+func TestStreamingTool_BelatedStartsUseOpenAIToolIndexOrder(t *testing.T) {
303+ events := runStream(t, streamReq,
304+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
305+ {"index":2,"function":{"name":"third_tool","arguments":"{}"}},
306+ {"index":0,"function":{"name":"first_tool","arguments":"{}"}},
307+ {"index":1,"function":{"name":"second_tool","arguments":"{}"}}
308+ ]}}]}`,
309+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
310+ )
311+
312+ starts := toolUseStarts(events)
313+ if len(starts) != 3 {
314+ t.Fatalf("expected three belated tool_use starts, got %d", len(starts))
315+ }
316+
317+ wantNames := []string{"first_tool", "second_tool", "third_tool"}
318+ for i, wantName := range wantNames {
319+ if name := gjson.Get(starts[i].Payload, "content_block.name").String(); name != wantName {
320+ t.Fatalf("tool_use start %d name = %q, want %q (starts=%+v)", i, name, wantName, starts)
321+ }
322+ if blockIndex := gjson.Get(starts[i].Payload, "index").Int(); blockIndex != int64(i) {
323+ t.Fatalf("tool_use start %d block index = %d, want %d", i, blockIndex, i)
324+ }
325+ }
326+}
327+
328+func TestStreamingTool_LateIDAfterFinalization(t *testing.T) {
329+ events := runStream(t, streamReq,
330+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
331+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
332+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late"}]}}]}`,
333+ )
334+
335+ starts := toolUseStarts(events)
336+ if len(starts) != 1 {
337+ t.Fatalf("expected one belated tool_use start, got %d", len(starts))
338+ }
339+
340+ var sawMessageStop bool
341+ for _, e := range events {
342+ if e.Type == "message_stop" {
343+ sawMessageStop = true
344+ continue
345+ }
346+ if sawMessageStop {
347+ switch e.Type {
348+ case "content_block_start", "content_block_delta", "content_block_stop":
349+ t.Fatalf("event %q emitted after message_stop (events=%+v)", e.Type, events)
350+ }
351+ }
352+ }
353+}
354+
355+func TestStreamingTool_StopReasonMixedSuppressedAndValid(t *testing.T) {
356+ events := runStream(t, streamReq,
357+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
358+ {"index":0,"id":"call_skip","function":{"name":"","arguments":""}},
359+ {"index":1,"id":"call_real","function":{"name":"do_it","arguments":"{}"}}
360+ ]}}]}`,
361+ `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
362+ )
363+ if got := lastStopReason(events); got != "tool_use" {
364+ t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
365+ }
366+}