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
8import (8import (
9 "bytes"9 "bytes"
10 "context"10 "context"
11 "sort"
11 "strings"12 "strings"
12 13
13 translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"14 translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
@@ -26,6 +27,9 @@ type ConvertOpenAIResponseToAnthropicParams struct { · 12 unmodified lines
26 Model string27 Model string
27 CreatedAt int6428 CreatedAt int64
28 ToolNameMap map[string]string29 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.
29 SawToolCall bool33 SawToolCall bool
30 // Content accumulator for streaming34 // Content accumulator for streaming
31 ContentAccumulator strings.Builder35 ContentAccumulator strings.Builder
@@ -60,6 +64,9 @@ type ToolCallAccumulator struct { · 28 unmodified lines
60 ID string64 ID string
61 Name string65 Name string
62 Arguments strings.Builder66 Arguments strings.Builder
67 // StartEmitted tracks whether content_block_start has already been sent
68 // for this tool index.
69 StartEmitted bool
63}70}
64 71
65// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.72// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.
@@ -218,9 +225,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 152 unmodified lines
218 }225 }
219 226
220 toolCalls.ForEach(func(_, toolCall gjson.Result) bool {227 toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
221 param.SawToolCall = true
222 index := int(toolCall.Get("index").Int())228 index := int(toolCall.Get("index").Int())
223 blockIndex := param.toolContentBlockIndex(index)
224 229
225 // Initialize accumulator if needed230 // Initialize accumulator if needed
226 if _, exists := param.ToolCallsAccumulator[index]; !exists {231 if _, exists := param.ToolCallsAccumulator[index]; !exists {
@@ -229,27 +234,25 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 2 unmodified lines
229 234
230 accumulator := param.ToolCallsAccumulator[index]235 accumulator := param.ToolCallsAccumulator[index]
231 236
232 // Handle tool call ID237 // Handle tool call ID. Only accept JSON-string, non-empty
233 if id := toolCall.Get("id"); id.Exists() {238 // values so malformed upstream fields do not overwrite a
234 accumulator.ID = id.String()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 }
235 }244 }
236 245
237 // Handle function name246 // Handle function name and arguments
238 if function := toolCall.Get("function"); function.Exists() {247 if function := toolCall.Get("function"); function.Exists() {
239 if name := function.Get("name"); name.Exists() && name.String() != "" {248 // Only record the name until content_block_start has been
240 accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())249 // emitted. Some upstreams send "name": "" or repeat the
241 250 // field across chunks; reassigning after start could drift
242 stopThinkingContentBlock(param, &results)251 // from what was already announced.
243 252 if !accumulator.StartEmitted {
244 stopTextContentBlock(param, &results)253 if name := function.Get("name"); name.Exists() && name.Type == gjson.String && name.String() != "" {
245 254 accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
246 // Send content_block_start for tool_use255 }
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))
253 }256 }
254 257
255 // Handle function arguments258 // Handle function arguments
@@ -261,6 +264,13 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 5 unmodified lines
261 }264 }
262 }265 }
263 266
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
264 return true274 return true
265 })275 })
266 }276 }
@@ -269,9 +279,12 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 2 unmodified lines
269 // Handle finish_reason (but don't send message_delta/message_stop yet)279 // Handle finish_reason (but don't send message_delta/message_stop yet)
270 if finishReason := root.Get("choices.0.finish_reason"); finishReason.Exists() && finishReason.String() != "" {280 if finishReason := root.Get("choices.0.finish_reason"); finishReason.Exists() && finishReason.String() != "" {
271 reason := finishReason.String()281 reason := finishReason.String()
272 if param.SawToolCall {282 switch {
283 case param.SawToolCall:
273 param.FinishReason = "tool_calls"284 param.FinishReason = "tool_calls"
274 } else {285 case reason == "tool_calls":
286 param.FinishReason = "stop"
287 default:
275 param.FinishReason = reason288 param.FinishReason = reason
276 }289 }
277 290
@@ -289,8 +302,17 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI · 11 unmodified lines
289 302
290 // Send content_block_stop for any tool calls303 // Send content_block_stop for any tool calls
291 if !param.ContentBlocksStopped {304 if !param.ContentBlocksStopped {
292 for index := range param.ToolCallsAccumulator {305 for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
293 accumulator := param.ToolCallsAccumulator[index]306 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 }
294 blockIndex := param.toolContentBlockIndex(index)316 blockIndex := param.toolContentBlockIndex(index)
295 317
296 // Send complete input_json_delta with all accumulated arguments318 // Send complete input_json_delta with all accumulated arguments
@@ -353,8 +375,16 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) · 56 unmodified lines
353 stopTextContentBlock(param, &results)375 stopTextContentBlock(param, &results)
354 376
355 if !param.ContentBlocksStopped {377 if !param.ContentBlocksStopped {
356 for index := range param.ToolCallsAccumulator {378 for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
357 accumulator := param.ToolCallsAccumulator[index]379 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 }
358 blockIndex := param.toolContentBlockIndex(index)388 blockIndex := param.toolContentBlockIndex(index)
359 389
360 if accumulator.Arguments.Len() > 0 {390 if accumulator.Arguments.Len() > 0 {
@@ -547,6 +577,29 @@ func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results · 186 unmodified lines
547 param.TextContentBlockIndex = -1577 param.TextContentBlockIndex = -1
548}578}
549 579
580func 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
594func 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
550// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.603// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.
551//604//
552// Parameters:605// Parameters:
internal/translator/openai/claude/openai_claude_response_test.go+326−1
@@ -3,11 +3,108 @@ package claude · 2 unmodified lines
3import (3import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "strings"
6 "testing"7 "testing"
8
9 "github.com/tidwall/gjson"
7)10)
8 11
12type sseEvent struct {
13 Type string
14 Payload string
15}
16
17func 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
62func 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
72func 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
85func 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
95func 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
104const streamReq = `{"stream":true}`
105
9func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {106func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
10 originalRequest := []byte(`{"stream":true}`)107 originalRequest := []byte(streamReq)
11 var param any108 var param any
12 109
13 firstChunks := ConvertOpenAIResponseToClaude(110 firstChunks := ConvertOpenAIResponseToClaude(
@@ -39,3 +136,231 @@ func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing · 25 unmodified lines
39 t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))136 t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
40 }137 }
41}138}
139
140func 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
161func 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
174func 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
184func 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
204func 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
230func 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
246func 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
269func 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
279func 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
302func 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
328func 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
355func 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}