tool_parser_streaming.rs 9.79 KB
Newer Older
1
//! Realistic Streaming Parser Tests
2
//!
3
4
5
6
7
//! Tests incremental parsing with realistic char-level chunks (2-5 chars)
//! that simulate how LLM tokens actually arrive.
//!
//! These tests are designed to catch bugs like `{"name": "` being parsed
//! as an empty tool name.
8

9
use sglang_router_rs::tool_parser::{JsonParser, LlamaParser, QwenParser, ToolParser};
10

11
mod common;
12
13
14
15
16
use common::{create_test_tools, streaming_helpers::*};

// =============================================================================
// THE BUG SCENARIO - Most Critical Test
// =============================================================================
17

18
#[tokio::test]
19
async fn test_json_bug_incomplete_tool_name_string() {
20
21
    let tools = create_test_tools();
    let mut parser = JsonParser::new();
22

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
    // This exact sequence triggered the bug:
    // Parser receives {"name": " and must NOT parse it as empty name
    let chunks = vec![
        r#"{"#,
        r#"""#,
        r#"name"#,
        r#"""#,
        r#":"#,
        r#" "#,
        r#"""#, // ← Critical moment: parser has {"name": "
        // At this point, partial_json should NOT allow incomplete strings
        // when current_tool_name_sent=false
        r#"search"#, // Use valid tool name from create_test_tools()
        r#"""#,
        r#", "#,
        r#"""#,
        r#"arguments"#,
        r#"""#,
        r#": {"#,
        r#"""#,
        r#"query"#,
        r#"""#,
        r#": "#,
        r#"""#,
        r#"rust programming"#,
        r#"""#,
        r#"}}"#,
    ];

    let mut got_tool_name = false;
    let mut saw_empty_name = false;

    for chunk in chunks.iter() {
        let result = parser.parse_incremental(chunk, &tools).await.unwrap();
57

58
59
60
61
62
63
64
65
66
67
68
        for call in result.calls {
            if let Some(name) = &call.name {
                if name.is_empty() {
                    saw_empty_name = true;
                }
                if name == "search" {
                    got_tool_name = true;
                }
            }
        }
    }
69

70
71
72
73
74
    assert!(
        !saw_empty_name,
        "Parser should NEVER return empty tool name"
    );
    assert!(got_tool_name, "Should have parsed tool name correctly");
75
76
}

77
78
79
80
// =============================================================================
// JSON PARSER REALISTIC STREAMING
// =============================================================================

81
#[tokio::test]
82
async fn test_json_realistic_chunks_simple_tool() {
83
84
    let tools = create_test_tools();
    let mut parser = JsonParser::new();
85

86
87
88
89
    let input = r#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#;
    let chunks = create_realistic_chunks(input);

    assert!(chunks.len() > 10, "Should have many small chunks");
90

91
    let mut got_tool_name = false;
92
93

    for chunk in chunks {
94
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
95
        for call in result.calls {
96
97
98
            if let Some(name) = call.name {
                assert_eq!(name, "get_weather");
                got_tool_name = true;
99
            }
100
101
102
        }
    }

103
    assert!(got_tool_name, "Should have parsed tool name");
104
105
106
}

#[tokio::test]
107
async fn test_json_strategic_chunks_with_quotes() {
108
    let tools = create_test_tools();
109
    let mut parser = JsonParser::new();
110

111
112
    let input = r#"{"name": "search", "arguments": {"query": "rust programming"}}"#;
    let chunks = create_strategic_chunks(input);
113

114
115
    // Strategic chunks break after quotes and colons
    assert!(chunks.iter().any(|c| c.ends_with('"')));
116

117
    let mut got_tool_name = false;
118
119

    for chunk in chunks {
120
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
121
        for call in result.calls {
122
            if call.name.is_some() {
123
124
                got_tool_name = true;
            }
125
126
127
        }
    }

128
    assert!(got_tool_name, "Should have parsed tool name");
129
130
131
}

#[tokio::test]
132
async fn test_json_incremental_arguments_streaming() {
133
    let tools = create_test_tools();
134
    let mut parser = JsonParser::new();
135

136
137
    let input = r#"{"name": "search", "arguments": {"query": "test", "limit": 10}}"#;
    let chunks = create_realistic_chunks(input);
138

139
140
    let mut tool_name_sent = false;
    let mut got_arguments = false;
141

142
143
144
145
146
147
148
149
150
151
152
    for chunk in chunks {
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
        for call in result.calls {
            if call.name.is_some() {
                tool_name_sent = true;
            }
            if tool_name_sent && !call.parameters.is_empty() {
                got_arguments = true;
            }
        }
    }
153

154
155
    assert!(tool_name_sent, "Should have sent tool name");
    assert!(got_arguments, "Should have sent arguments");
156
157
}

158
159
160
161
// =============================================================================
// LLAMA PARSER REALISTIC STREAMING
// =============================================================================

162
#[tokio::test]
163
async fn test_llama_realistic_chunks_with_python_tag() {
164
165
    let tools = create_test_tools();
    let mut parser = LlamaParser::new();
166

167
168
169
170
    let input = r#"<|python_tag|>{"name": "calculate", "parameters": {"x": 10, "y": 20}}"#;
    let chunks = create_realistic_chunks(input);

    assert!(chunks.len() > 15, "Should have many small chunks");
171

172
    let mut got_tool_name = false;
173
174

    for chunk in chunks {
175
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
176
177
178
179
180
        for call in result.calls {
            if let Some(name) = call.name {
                assert_eq!(name, "calculate");
                got_tool_name = true;
            }
181
182
183
        }
    }

184
    assert!(got_tool_name, "Should have parsed tool name");
185
186
187
}

#[tokio::test]
188
async fn test_llama_python_tag_arrives_in_parts() {
189
    let tools = create_test_tools();
190
    let mut parser = LlamaParser::new();
191

192
193
194
195
196
197
    // Python tag itself arrives in small chunks
    let chunks = vec![
        "<|p", "yth", "on_", "tag", "|>{", r#"""#, "na", r#"me""#, ": ", r#"""#, "sea", "rch",
        r#"""#, ", ", r#"""#, "par", "ame", "ter", "s", r#"""#, ": {", r#"""#, "q", r#"""#, ": ",
        r#"""#, "tes", "t", r#"""#, "}}",
    ];
198

199
    let mut got_tool_name = false;
200
201

    for chunk in chunks {
202
        let result = parser.parse_incremental(chunk, &tools).await.unwrap();
203
204
205
206
207
208
        for call in result.calls {
            if let Some(name) = call.name {
                assert_eq!(name, "search");
                got_tool_name = true;
            }
        }
209
    }
210
211

    assert!(got_tool_name, "Should have parsed tool name");
212
213
}

214
215
216
217
// =============================================================================
// QWEN PARSER REALISTIC STREAMING
// =============================================================================

218
#[tokio::test]
219
async fn test_qwen_realistic_chunks_with_xml_tags() {
220
    let tools = create_test_tools();
221
    let mut parser = QwenParser::new();
222

223
224
    let input = "<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Tokyo\"}}\n</tool_call>";
    let chunks = create_realistic_chunks(input);
225

226
    assert!(chunks.len() > 20, "Should have many small chunks");
227

228
    let mut got_tool_name = false;
229

230
231
232
233
234
235
236
237
238
    for chunk in chunks {
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
        for call in result.calls {
            if let Some(name) = call.name {
                assert_eq!(name, "get_weather");
                got_tool_name = true;
            }
        }
    }
239

240
    assert!(got_tool_name, "Should have parsed tool name");
241
242
243
}

#[tokio::test]
244
async fn test_qwen_xml_tag_arrives_in_parts() {
245
246
    let tools = create_test_tools();
    let mut parser = QwenParser::new();
247

248
249
250
251
252
    let chunks = vec![
        "<to", "ol_", "cal", "l>\n", "{", r#"""#, "na", "me", r#"""#, ": ", r#"""#, "tra", "nsl",
        "ate", r#"""#, ", ", r#"""#, "arg", "ume", "nts", r#"""#, ": {", r#"""#, "tex", "t",
        r#"""#, ": ", r#"""#, "hel", "lo", r#"""#, "}}\n", "</t", "ool", "_ca", "ll>",
    ];
253

254
    let mut got_tool_name = false;
255

256
257
258
259
260
261
262
263
264
    for chunk in chunks {
        let result = parser.parse_incremental(chunk, &tools).await.unwrap();
        for call in result.calls {
            if let Some(name) = call.name {
                assert_eq!(name, "translate");
                got_tool_name = true;
            }
        }
    }
265

266
    assert!(got_tool_name, "Should have parsed tool name");
267
268
}

269
270
271
272
// =============================================================================
// EDGE CASES WITH REALISTIC CHUNKS
// =============================================================================

273
#[tokio::test]
274
async fn test_json_very_long_url_in_arguments() {
275
276
    let tools = create_test_tools();
    let mut parser = JsonParser::new();
277

278
279
280
281
282
283
284
    // Simulate long URL arriving in many chunks
    let long_url = "https://example.com/very/long/path/".to_string() + &"segment/".repeat(50);
    let input = format!(
        r#"{{"name": "search", "arguments": {{"query": "{}"}}}}"#,
        long_url
    );
    let chunks = create_realistic_chunks(&input);
285

286
    assert!(chunks.len() > 100, "Long URL should create many chunks");
287

288
    let mut got_tool_name = false;
289

290
291
292
293
294
295
296
    for chunk in chunks {
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
        for call in result.calls {
            if call.name.is_some() {
                got_tool_name = true;
            }
        }
297
298
    }

299
    assert!(got_tool_name, "Should have parsed tool name");
300
301
302
}

#[tokio::test]
303
async fn test_json_unicode_arrives_byte_by_byte() {
304
    let tools = create_test_tools();
305
    let mut parser = JsonParser::new();
306

307
308
    let input = r#"{"name": "search", "arguments": {"query": "Hello 世界 🌍"}}"#;
    let chunks = create_realistic_chunks(input);
309

310
    let mut got_tool_name = false;
311

312
313
314
315
316
317
318
    for chunk in chunks {
        let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
        for call in result.calls {
            if call.name.is_some() {
                got_tool_name = true;
            }
        }
319
320
    }

321
    assert!(got_tool_name, "Should have parsed with unicode");
322
}