| @@ -3,11 +3,108 @@ package claude · 2 unmodified lines |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | + "strings" |
| 6 | 7 | "testing" |
| 8 | + |
| 9 | + "github.com/tidwall/gjson" |
| 7 | 10 | ) |
| 8 | 11 | |
| 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 | + ¶mAny, |
| 30 | + )...) |
| 31 | + } |
| 32 | + emitted = append(emitted, ConvertOpenAIResponseToClaude( |
| 33 | + context.Background(), |
| 34 | + "", |
| 35 | + []byte(originalReq), |
| 36 | + nil, |
| 37 | + []byte("data: [DONE]"), |
| 38 | + ¶mAny, |
| 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 | + |
| 9 | 106 | func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) { |
| 10 | | − originalRequest := []byte(`{"stream":true}`) |
| 107 | + originalRequest := []byte(streamReq) |
| 11 | 108 | var param any |
| 12 | 109 | |
| 13 | 110 | firstChunks := ConvertOpenAIResponseToClaude( |
| @@ -39,3 +136,231 @@ func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing · 25 unmodified lines |
| 39 | 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 | + |
| 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 | +} |