pygeai 0.7.0b3__py3-none-any.whl → 0.7.0b4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygeai/chat/clients.py +56 -32
- pygeai/core/handlers.py +27 -14
- pygeai/core/utils/parsers.py +33 -12
- pygeai/lab/processes/endpoints.py +1 -1
- pygeai/tests/chat/test_streaming_json.py +436 -0
- pygeai/tests/cli/commands/test_json_parsing.py +419 -0
- pygeai/tests/core/utils/test_parsers.py +379 -0
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/METADATA +1 -1
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/RECORD +13 -10
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/WHEEL +0 -0
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/entry_points.txt +0 -0
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/licenses/LICENSE +0 -0
- {pygeai-0.7.0b3.dist-info → pygeai-0.7.0b4.dist-info}/top_level.txt +0 -0
pygeai/chat/clients.py
CHANGED
|
@@ -178,27 +178,39 @@ class ChatClient(BaseClient):
|
|
|
178
178
|
"""
|
|
179
179
|
Processes a streaming response and yields content strings.
|
|
180
180
|
|
|
181
|
+
Optimized for performance:
|
|
182
|
+
- Early exits to avoid unnecessary processing
|
|
183
|
+
- Reduced string operations
|
|
184
|
+
- Single dict lookups with guards
|
|
185
|
+
|
|
181
186
|
:param response: The streaming response from the API.
|
|
182
187
|
:return: Generator[str, None, None] - Yields content strings extracted from streaming chunks.
|
|
183
188
|
"""
|
|
184
189
|
try:
|
|
185
190
|
for line in response:
|
|
186
|
-
if line.startswith("data:"):
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
191
|
+
if not line.startswith("data:"):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
chunk = line[5:].strip()
|
|
195
|
+
if chunk == "[DONE]":
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
json_data = json.loads(chunk)
|
|
200
|
+
except JSONDecodeError:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
choices = json_data.get("choices")
|
|
204
|
+
if not choices:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
first_choice = choices[0] if choices else None
|
|
208
|
+
if not first_choice:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
delta = first_choice.get("delta")
|
|
212
|
+
if delta and "content" in delta:
|
|
213
|
+
yield delta["content"]
|
|
202
214
|
except Exception as e:
|
|
203
215
|
raise InvalidAPIResponseException(f"Unable to process streaming chat response: {e}")
|
|
204
216
|
|
|
@@ -206,27 +218,39 @@ class ChatClient(BaseClient):
|
|
|
206
218
|
"""
|
|
207
219
|
Processes a streaming response from the Responses API and yields content strings.
|
|
208
220
|
|
|
221
|
+
Optimized for performance:
|
|
222
|
+
- Early exits to avoid unnecessary processing
|
|
223
|
+
- Reduced string operations
|
|
224
|
+
- Single dict lookups with guards
|
|
225
|
+
|
|
209
226
|
:param response: The streaming response from the API.
|
|
210
227
|
:return: Generator[str, None, None] - Yields content strings extracted from streaming chunks.
|
|
211
228
|
"""
|
|
212
229
|
try:
|
|
213
230
|
for line in response:
|
|
214
|
-
if line.startswith("data:"):
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
if not line.startswith("data:"):
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
chunk = line[5:].strip()
|
|
235
|
+
if chunk == "[DONE]":
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
json_data = json.loads(chunk)
|
|
240
|
+
except JSONDecodeError:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
choices = json_data.get("choices")
|
|
244
|
+
if not choices:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
first_choice = choices[0] if choices else None
|
|
248
|
+
if not first_choice:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
delta = first_choice.get("delta")
|
|
252
|
+
if delta and "content" in delta:
|
|
253
|
+
yield delta["content"]
|
|
230
254
|
except Exception as e:
|
|
231
255
|
raise InvalidAPIResponseException(f"Unable to process streaming response: {e}")
|
|
232
256
|
|
pygeai/core/handlers.py
CHANGED
|
@@ -5,23 +5,36 @@ class ErrorHandler:
|
|
|
5
5
|
|
|
6
6
|
@classmethod
|
|
7
7
|
def has_errors(cls, response):
|
|
8
|
-
|
|
9
|
-
if
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
8
|
+
"""
|
|
9
|
+
Check if the response contains errors.
|
|
10
|
+
|
|
11
|
+
Handles both dict and string responses for backward compatibility.
|
|
12
|
+
Optimized to reduce redundant dictionary lookups when response is a dict.
|
|
13
|
+
|
|
14
|
+
:param response: Response (dict or string) to check
|
|
15
|
+
:return: True if errors found, False otherwise
|
|
16
|
+
"""
|
|
17
|
+
if "errors" in response or "error" in response:
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
if not isinstance(response, dict):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
message = response.get("message")
|
|
24
|
+
return (
|
|
25
|
+
isinstance(message, list) and
|
|
26
|
+
message and
|
|
27
|
+
message[0].get("type") == "error"
|
|
28
|
+
)
|
|
22
29
|
|
|
23
30
|
@classmethod
|
|
24
31
|
def extract_error(cls, response):
|
|
32
|
+
"""
|
|
33
|
+
Extract and map error information from response.
|
|
34
|
+
|
|
35
|
+
:param response: Response dictionary containing error
|
|
36
|
+
:return: Mapped error object
|
|
37
|
+
"""
|
|
25
38
|
if "errors" in response:
|
|
26
39
|
result = ErrorMapper.map_to_error_list_response(response)
|
|
27
40
|
elif "error" in response:
|
pygeai/core/utils/parsers.py
CHANGED
|
@@ -8,6 +8,9 @@ def parse_json_response(response, operation: str, **context):
|
|
|
8
8
|
"""
|
|
9
9
|
Parse JSON response with standardized error handling.
|
|
10
10
|
|
|
11
|
+
Optimized to keep the hot path (success case) fast by separating
|
|
12
|
+
error handling logic into a dedicated function.
|
|
13
|
+
|
|
11
14
|
:param response: HTTP response object
|
|
12
15
|
:param operation: Description of operation (e.g., "get project API token")
|
|
13
16
|
:param context: Additional context (e.g., api_token_id="123")
|
|
@@ -17,16 +20,34 @@ def parse_json_response(response, operation: str, **context):
|
|
|
17
20
|
try:
|
|
18
21
|
return response.json()
|
|
19
22
|
except JSONDecodeError as e:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if len(context) == 1:
|
|
23
|
-
# Single context value: append as 'value'
|
|
24
|
-
value = list(context.values())[0]
|
|
25
|
-
full_msg += f" '{value}'"
|
|
26
|
-
else:
|
|
27
|
-
# Multiple context values: format as (key1='value1', key2='value2')
|
|
28
|
-
context_str = ", ".join([f"{k}='{v}'" for k, v in context.items()])
|
|
29
|
-
full_msg += f" ({context_str})"
|
|
23
|
+
_handle_json_parse_error(response, operation, context, e)
|
|
24
|
+
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
def _handle_json_parse_error(response, operation: str, context: dict, error: JSONDecodeError):
|
|
27
|
+
"""
|
|
28
|
+
Handle JSON parsing errors with detailed error messages.
|
|
29
|
+
|
|
30
|
+
Separated from main parsing function to optimize the hot path.
|
|
31
|
+
This function is only called when parsing fails (rare case).
|
|
32
|
+
|
|
33
|
+
:param response: HTTP response object
|
|
34
|
+
:param operation: Description of operation
|
|
35
|
+
:param context: Additional context parameters
|
|
36
|
+
:param error: The JSONDecodeError that occurred
|
|
37
|
+
:raises InvalidAPIResponseException: Always raises with detailed message
|
|
38
|
+
"""
|
|
39
|
+
full_msg = f"Unable to {operation}"
|
|
40
|
+
|
|
41
|
+
if context:
|
|
42
|
+
if len(context) == 1:
|
|
43
|
+
value = next(iter(context.values()))
|
|
44
|
+
full_msg += f" '{value}'"
|
|
45
|
+
else:
|
|
46
|
+
context_str = ", ".join(f"{k}='{v}'" for k, v in context.items())
|
|
47
|
+
full_msg += f" ({context_str})"
|
|
48
|
+
|
|
49
|
+
logger.error(
|
|
50
|
+
f"{full_msg}: JSON parsing error (status {response.status_code}): {error}. "
|
|
51
|
+
f"Response: {response.text}"
|
|
52
|
+
)
|
|
53
|
+
raise InvalidAPIResponseException(f"{full_msg}: {response.text}")
|
|
@@ -2,7 +2,7 @@ CREATE_PROCESS_V2 = "v2/processes" # POST -> Create process
|
|
|
2
2
|
UPDATE_PROCESS_V2 = "v2/processes/{processId}" # PUT -> Update process
|
|
3
3
|
UPSERT_PROCESS_V2 = "v2/processes/{processId}/upsert" # PUT -> Update or insert process
|
|
4
4
|
GET_PROCESS_V2 = "v2/processes/{processId}" # GET -> Get process
|
|
5
|
-
LIST_PROCESSES_V2 = "v2/processes" # GET -> Get process
|
|
5
|
+
LIST_PROCESSES_V2 = "v2/processes" # GET -> Get process list
|
|
6
6
|
LIST_PROCESS_INSTANCES_V2 = "v2/processes/{processId}/instances" # GET -> Get process instances
|
|
7
7
|
DELETE_PROCESS_V2 = "v2/processes/{processId}" # DELETE -> Get process
|
|
8
8
|
PUBLISH_PROCESS_REVISION_V2 = "v2/processes/{processId}/publish-revision" # POST -> Publish process revision
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import json
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
from pygeai.chat.clients import ChatClient
|
|
6
|
+
from pygeai.core.common.exceptions import InvalidAPIResponseException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestStreamingJsonParsing(unittest.TestCase):
|
|
10
|
+
"""
|
|
11
|
+
Tests for JSON parsing in streaming responses.
|
|
12
|
+
|
|
13
|
+
These tests validate the current behavior of json.loads() in streaming
|
|
14
|
+
generators to ensure optimizations don't break functionality.
|
|
15
|
+
|
|
16
|
+
Run with:
|
|
17
|
+
python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsing
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def setUp(self):
|
|
21
|
+
"""Set up test client"""
|
|
22
|
+
self.client = ChatClient(api_key="test_key", base_url="test_url")
|
|
23
|
+
self.client.api_service = MagicMock()
|
|
24
|
+
|
|
25
|
+
def test_parse_streaming_chunk_valid(self):
|
|
26
|
+
"""Test parsing valid streaming JSON chunk"""
|
|
27
|
+
chunk = '{"choices": [{"delta": {"content": "Hello"}}]}'
|
|
28
|
+
|
|
29
|
+
result = json.loads(chunk)
|
|
30
|
+
|
|
31
|
+
self.assertIn("choices", result)
|
|
32
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "Hello")
|
|
33
|
+
|
|
34
|
+
def test_parse_streaming_chunk_invalid(self):
|
|
35
|
+
"""Test that invalid streaming chunk raises JSONDecodeError"""
|
|
36
|
+
chunk = 'invalid json'
|
|
37
|
+
|
|
38
|
+
with self.assertRaises(JSONDecodeError):
|
|
39
|
+
json.loads(chunk)
|
|
40
|
+
|
|
41
|
+
def test_parse_streaming_chunk_empty_content(self):
|
|
42
|
+
"""Test parsing chunk with empty content field"""
|
|
43
|
+
chunk = '{"choices": [{"delta": {"content": ""}}]}'
|
|
44
|
+
|
|
45
|
+
result = json.loads(chunk)
|
|
46
|
+
|
|
47
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "")
|
|
48
|
+
|
|
49
|
+
def test_parse_streaming_chunk_no_content(self):
|
|
50
|
+
"""Test parsing chunk without content field"""
|
|
51
|
+
chunk = '{"choices": [{"delta": {}}]}'
|
|
52
|
+
|
|
53
|
+
result = json.loads(chunk)
|
|
54
|
+
|
|
55
|
+
self.assertNotIn("content", result["choices"][0]["delta"])
|
|
56
|
+
|
|
57
|
+
def test_parse_streaming_chunk_multiple_choices(self):
|
|
58
|
+
"""Test parsing chunk with multiple choices"""
|
|
59
|
+
chunk = '{"choices": [{"delta": {"content": "A"}}, {"delta": {"content": "B"}}]}'
|
|
60
|
+
|
|
61
|
+
result = json.loads(chunk)
|
|
62
|
+
|
|
63
|
+
self.assertEqual(len(result["choices"]), 2)
|
|
64
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "A")
|
|
65
|
+
|
|
66
|
+
def test_stream_chat_generator_success(self):
|
|
67
|
+
"""Test stream_chat_generator with valid data"""
|
|
68
|
+
streaming_data = [
|
|
69
|
+
'data: {"choices": [{"delta": {"content": "Hello"}}]}',
|
|
70
|
+
'data: {"choices": [{"delta": {"content": " world"}}]}',
|
|
71
|
+
'data: {"choices": [{"delta": {"content": "!"}}]}',
|
|
72
|
+
'data: [DONE]'
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
mock_response = iter(streaming_data)
|
|
76
|
+
|
|
77
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
78
|
+
|
|
79
|
+
self.assertEqual(result, ["Hello", " world", "!"])
|
|
80
|
+
|
|
81
|
+
def test_stream_chat_generator_skips_invalid_json(self):
|
|
82
|
+
"""Test that generator skips chunks with invalid JSON"""
|
|
83
|
+
streaming_data = [
|
|
84
|
+
'data: {"choices": [{"delta": {"content": "Valid"}}]}',
|
|
85
|
+
'data: invalid json here', # Should be skipped
|
|
86
|
+
'data: {"choices": [{"delta": {"content": " content"}}]}',
|
|
87
|
+
'data: [DONE]'
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
mock_response = iter(streaming_data)
|
|
91
|
+
|
|
92
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
93
|
+
|
|
94
|
+
# Should only get valid chunks
|
|
95
|
+
self.assertEqual(result, ["Valid", " content"])
|
|
96
|
+
|
|
97
|
+
def test_stream_chat_generator_skips_malformed_structure(self):
|
|
98
|
+
"""Test that generator skips chunks with malformed structure"""
|
|
99
|
+
streaming_data = [
|
|
100
|
+
'data: {"choices": [{"delta": {"content": "Good"}}]}',
|
|
101
|
+
'data: {"no_choices": "here"}', # Malformed - no choices
|
|
102
|
+
'data: {"choices": []}', # Empty choices
|
|
103
|
+
'data: {"choices": [{"no_delta": "here"}]}', # No delta
|
|
104
|
+
'data: {"choices": [{"delta": {"content": " data"}}]}',
|
|
105
|
+
'data: [DONE]'
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
mock_response = iter(streaming_data)
|
|
109
|
+
|
|
110
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
111
|
+
|
|
112
|
+
self.assertEqual(result, ["Good", " data"])
|
|
113
|
+
|
|
114
|
+
def test_stream_chat_generator_done_signal(self):
|
|
115
|
+
"""Test that [DONE] signal stops iteration"""
|
|
116
|
+
streaming_data = [
|
|
117
|
+
'data: {"choices": [{"delta": {"content": "Before"}}]}',
|
|
118
|
+
'data: [DONE]',
|
|
119
|
+
'data: {"choices": [{"delta": {"content": "After"}}]}' # Should not be processed
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
mock_response = iter(streaming_data)
|
|
123
|
+
|
|
124
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
125
|
+
|
|
126
|
+
# Should only get content before [DONE]
|
|
127
|
+
self.assertEqual(result, ["Before"])
|
|
128
|
+
|
|
129
|
+
def test_stream_chat_generator_non_data_lines(self):
|
|
130
|
+
"""Test that non-data lines are ignored"""
|
|
131
|
+
streaming_data = [
|
|
132
|
+
'event: start',
|
|
133
|
+
'data: {"choices": [{"delta": {"content": "Content"}}]}',
|
|
134
|
+
': comment line',
|
|
135
|
+
'data: {"choices": [{"delta": {"content": " here"}}]}',
|
|
136
|
+
'data: [DONE]'
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
mock_response = iter(streaming_data)
|
|
140
|
+
|
|
141
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
142
|
+
|
|
143
|
+
self.assertEqual(result, ["Content", " here"])
|
|
144
|
+
|
|
145
|
+
def test_stream_response_generator_success(self):
|
|
146
|
+
"""Test stream_response_generator with valid data"""
|
|
147
|
+
streaming_data = [
|
|
148
|
+
'data: {"choices": [{"delta": {"content": "Test"}}]}',
|
|
149
|
+
'data: {"choices": [{"delta": {"content": " response"}}]}',
|
|
150
|
+
'data: [DONE]'
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
mock_response = iter(streaming_data)
|
|
154
|
+
|
|
155
|
+
result = list(self.client.stream_response_generator(mock_response))
|
|
156
|
+
|
|
157
|
+
self.assertEqual(result, ["Test", " response"])
|
|
158
|
+
|
|
159
|
+
def test_stream_generator_with_whitespace(self):
|
|
160
|
+
"""Test streaming with various whitespace in data"""
|
|
161
|
+
streaming_data = [
|
|
162
|
+
'data: {"choices": [{"delta": {"content": "A"}}]} ', # Extra spaces
|
|
163
|
+
'data:\t{"choices": [{"delta": {"content": "B"}}]}\t', # Tabs
|
|
164
|
+
'data:{"choices": [{"delta": {"content": "C"}}]}', # No space after colon
|
|
165
|
+
'data: [DONE]'
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
mock_response = iter(streaming_data)
|
|
169
|
+
|
|
170
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
171
|
+
|
|
172
|
+
self.assertEqual(result, ["A", "B", "C"])
|
|
173
|
+
|
|
174
|
+
def test_stream_generator_error_handling(self):
|
|
175
|
+
"""Test that streaming errors are caught and raised appropriately"""
|
|
176
|
+
def error_response():
|
|
177
|
+
yield 'data: {"choices": [{"delta": {"content": "Start"}}]}'
|
|
178
|
+
raise Exception("Streaming error")
|
|
179
|
+
|
|
180
|
+
mock_response = error_response()
|
|
181
|
+
|
|
182
|
+
with self.assertRaises(InvalidAPIResponseException) as context:
|
|
183
|
+
list(self.client.stream_chat_generator(mock_response))
|
|
184
|
+
|
|
185
|
+
self.assertIn("Unable to process streaming chat response", str(context.exception))
|
|
186
|
+
|
|
187
|
+
def test_streaming_chunk_with_special_characters(self):
|
|
188
|
+
"""Test streaming chunks with special characters"""
|
|
189
|
+
chunk = '{"choices": [{"delta": {"content": "Hello \\"world\\" 🌍"}}]}'
|
|
190
|
+
|
|
191
|
+
result = json.loads(chunk)
|
|
192
|
+
|
|
193
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], 'Hello "world" 🌍')
|
|
194
|
+
|
|
195
|
+
def test_streaming_chunk_with_unicode(self):
|
|
196
|
+
"""Test streaming chunks with unicode characters"""
|
|
197
|
+
chunk = '{"choices": [{"delta": {"content": "こんにちは世界"}}]}'
|
|
198
|
+
|
|
199
|
+
result = json.loads(chunk)
|
|
200
|
+
|
|
201
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "こんにちは世界")
|
|
202
|
+
|
|
203
|
+
def test_streaming_chunk_with_newlines(self):
|
|
204
|
+
"""Test streaming chunks with newline characters"""
|
|
205
|
+
chunk = '{"choices": [{"delta": {"content": "Line 1\\nLine 2\\nLine 3"}}]}'
|
|
206
|
+
|
|
207
|
+
result = json.loads(chunk)
|
|
208
|
+
|
|
209
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "Line 1\nLine 2\nLine 3")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestStreamingJsonParsingEdgeCases(unittest.TestCase):
|
|
213
|
+
"""
|
|
214
|
+
Edge case tests for streaming JSON parsing.
|
|
215
|
+
|
|
216
|
+
Run with:
|
|
217
|
+
python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsingEdgeCases
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def setUp(self):
|
|
221
|
+
"""Set up test client"""
|
|
222
|
+
self.client = ChatClient(api_key="test_key", base_url="test_url")
|
|
223
|
+
self.client.api_service = MagicMock()
|
|
224
|
+
|
|
225
|
+
def test_empty_streaming_response(self):
|
|
226
|
+
"""Test handling of empty streaming response"""
|
|
227
|
+
streaming_data = []
|
|
228
|
+
mock_response = iter(streaming_data)
|
|
229
|
+
|
|
230
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
231
|
+
|
|
232
|
+
self.assertEqual(result, [])
|
|
233
|
+
|
|
234
|
+
def test_only_done_signal(self):
|
|
235
|
+
"""Test streaming with only DONE signal"""
|
|
236
|
+
streaming_data = ['data: [DONE]']
|
|
237
|
+
mock_response = iter(streaming_data)
|
|
238
|
+
|
|
239
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
240
|
+
|
|
241
|
+
self.assertEqual(result, [])
|
|
242
|
+
|
|
243
|
+
def test_streaming_with_empty_chunks(self):
|
|
244
|
+
"""Test streaming with empty content chunks"""
|
|
245
|
+
streaming_data = [
|
|
246
|
+
'data: {"choices": [{"delta": {"content": ""}}]}',
|
|
247
|
+
'data: {"choices": [{"delta": {"content": ""}}]}',
|
|
248
|
+
'data: [DONE]'
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
mock_response = iter(streaming_data)
|
|
252
|
+
|
|
253
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
254
|
+
|
|
255
|
+
# Empty strings are still yielded
|
|
256
|
+
self.assertEqual(result, ["", ""])
|
|
257
|
+
|
|
258
|
+
def test_streaming_with_very_long_content(self):
|
|
259
|
+
"""Test streaming with very long content chunk"""
|
|
260
|
+
long_content = "A" * 10000
|
|
261
|
+
chunk = f'{{"choices": [{{"delta": {{"content": "{long_content}"}}}}]}}'
|
|
262
|
+
streaming_data = [
|
|
263
|
+
f'data: {chunk}',
|
|
264
|
+
'data: [DONE]'
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
mock_response = iter(streaming_data)
|
|
268
|
+
|
|
269
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
270
|
+
|
|
271
|
+
self.assertEqual(len(result[0]), 10000)
|
|
272
|
+
|
|
273
|
+
def test_streaming_with_nested_json_in_content(self):
|
|
274
|
+
"""Test streaming where content itself contains JSON string"""
|
|
275
|
+
content = '{\\"nested\\": \\"value\\"}'
|
|
276
|
+
chunk = f'{{"choices": [{{"delta": {{"content": "{content}"}}}}]}}'
|
|
277
|
+
streaming_data = [
|
|
278
|
+
f'data: {chunk}',
|
|
279
|
+
'data: [DONE]'
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
mock_response = iter(streaming_data)
|
|
283
|
+
|
|
284
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
285
|
+
|
|
286
|
+
self.assertEqual(result[0], '{"nested": "value"}')
|
|
287
|
+
|
|
288
|
+
def test_streaming_choices_out_of_bounds_protection(self):
|
|
289
|
+
"""Test that accessing choices[0] is safe"""
|
|
290
|
+
# Chunk with empty choices array
|
|
291
|
+
streaming_data = [
|
|
292
|
+
'data: {"choices": []}',
|
|
293
|
+
'data: [DONE]'
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
mock_response = iter(streaming_data)
|
|
297
|
+
|
|
298
|
+
# Should not raise IndexError - chunk should be skipped
|
|
299
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
300
|
+
|
|
301
|
+
self.assertEqual(result, [])
|
|
302
|
+
|
|
303
|
+
def test_streaming_delta_missing_content_key(self):
|
|
304
|
+
"""Test chunks where delta exists but no content key"""
|
|
305
|
+
streaming_data = [
|
|
306
|
+
'data: {"choices": [{"delta": {"role": "assistant"}}]}', # No content
|
|
307
|
+
'data: {"choices": [{"delta": {"content": "Valid"}}]}',
|
|
308
|
+
'data: [DONE]'
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
mock_response = iter(streaming_data)
|
|
312
|
+
|
|
313
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
314
|
+
|
|
315
|
+
# Only chunk with content should be yielded
|
|
316
|
+
self.assertEqual(result, ["Valid"])
|
|
317
|
+
|
|
318
|
+
def test_streaming_multiple_rapid_chunks(self):
|
|
319
|
+
"""Test rapid succession of chunks (simulates fast streaming)"""
|
|
320
|
+
streaming_data = [f'data: {{"choices": [{{"delta": {{"content": "{i}"}}}}]}}' for i in range(100)]
|
|
321
|
+
streaming_data.append('data: [DONE]')
|
|
322
|
+
|
|
323
|
+
mock_response = iter(streaming_data)
|
|
324
|
+
|
|
325
|
+
result = list(self.client.stream_chat_generator(mock_response))
|
|
326
|
+
|
|
327
|
+
self.assertEqual(len(result), 100)
|
|
328
|
+
self.assertEqual(result[0], "0")
|
|
329
|
+
self.assertEqual(result[99], "99")
|
|
330
|
+
|
|
331
|
+
def test_streaming_chunk_access_pattern(self):
|
|
332
|
+
"""Test the nested dictionary access pattern used in streaming"""
|
|
333
|
+
chunk = '{"choices": [{"delta": {"content": "test"}}]}'
|
|
334
|
+
json_data = json.loads(chunk)
|
|
335
|
+
|
|
336
|
+
# This is the exact pattern used in the code
|
|
337
|
+
if (
|
|
338
|
+
json_data.get("choices")
|
|
339
|
+
and len(json_data["choices"]) > 0
|
|
340
|
+
and "delta" in json_data["choices"][0]
|
|
341
|
+
and "content" in json_data["choices"][0]["delta"]
|
|
342
|
+
):
|
|
343
|
+
content = json_data["choices"][0]["delta"]["content"]
|
|
344
|
+
self.assertEqual(content, "test")
|
|
345
|
+
else:
|
|
346
|
+
self.fail("Access pattern failed")
|
|
347
|
+
|
|
348
|
+
def test_streaming_chunk_safe_get_pattern(self):
|
|
349
|
+
"""Test safe dictionary access with .get()"""
|
|
350
|
+
# Test with valid structure
|
|
351
|
+
chunk1 = '{"choices": [{"delta": {"content": "test"}}]}'
|
|
352
|
+
data1 = json.loads(chunk1)
|
|
353
|
+
choices = data1.get("choices")
|
|
354
|
+
self.assertIsNotNone(choices)
|
|
355
|
+
|
|
356
|
+
# Test with missing key
|
|
357
|
+
chunk2 = '{"no_choices": []}'
|
|
358
|
+
data2 = json.loads(chunk2)
|
|
359
|
+
choices = data2.get("choices")
|
|
360
|
+
self.assertIsNone(choices)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class TestStreamingJsonParsingPerformance(unittest.TestCase):
|
|
364
|
+
"""
|
|
365
|
+
Performance-related tests for streaming JSON parsing.
|
|
366
|
+
|
|
367
|
+
These tests ensure the current implementation can handle realistic scenarios.
|
|
368
|
+
|
|
369
|
+
Run with:
|
|
370
|
+
python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsingPerformance
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def test_parse_many_chunks_sequentially(self):
|
|
374
|
+
"""Test parsing many chunks in sequence (baseline performance test)"""
|
|
375
|
+
chunks = [
|
|
376
|
+
f'{{"choices": [{{"delta": {{"content": "chunk{i}"}}}}]}}'
|
|
377
|
+
for i in range(1000)
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
# Measure baseline performance
|
|
381
|
+
for chunk in chunks:
|
|
382
|
+
result = json.loads(chunk)
|
|
383
|
+
self.assertIn("choices", result)
|
|
384
|
+
|
|
385
|
+
def test_parse_complex_streaming_chunks(self):
|
|
386
|
+
"""Test parsing complex streaming chunks with nested data"""
|
|
387
|
+
chunk = '''
|
|
388
|
+
{
|
|
389
|
+
"id": "chatcmpl-123",
|
|
390
|
+
"object": "chat.completion.chunk",
|
|
391
|
+
"created": 1694268190,
|
|
392
|
+
"model": "gpt-4",
|
|
393
|
+
"choices": [{
|
|
394
|
+
"index": 0,
|
|
395
|
+
"delta": {
|
|
396
|
+
"role": "assistant",
|
|
397
|
+
"content": "Hello"
|
|
398
|
+
},
|
|
399
|
+
"finish_reason": null
|
|
400
|
+
}]
|
|
401
|
+
}
|
|
402
|
+
'''
|
|
403
|
+
|
|
404
|
+
result = json.loads(chunk)
|
|
405
|
+
|
|
406
|
+
self.assertEqual(result["choices"][0]["delta"]["content"], "Hello")
|
|
407
|
+
self.assertEqual(result["model"], "gpt-4")
|
|
408
|
+
|
|
409
|
+
def test_parse_minimal_vs_verbose_chunks(self):
|
|
410
|
+
"""Test parsing both minimal and verbose chunk formats"""
|
|
411
|
+
# Minimal
|
|
412
|
+
minimal = '{"choices": [{"delta": {"content": "A"}}]}'
|
|
413
|
+
result1 = json.loads(minimal)
|
|
414
|
+
self.assertEqual(result1["choices"][0]["delta"]["content"], "A")
|
|
415
|
+
|
|
416
|
+
# Verbose (more realistic from API)
|
|
417
|
+
verbose = '''
|
|
418
|
+
{
|
|
419
|
+
"id": "123",
|
|
420
|
+
"object": "chat.completion.chunk",
|
|
421
|
+
"created": 1234567890,
|
|
422
|
+
"model": "model-name",
|
|
423
|
+
"choices": [{
|
|
424
|
+
"index": 0,
|
|
425
|
+
"delta": {"content": "A"},
|
|
426
|
+
"finish_reason": null
|
|
427
|
+
}],
|
|
428
|
+
"usage": null
|
|
429
|
+
}
|
|
430
|
+
'''
|
|
431
|
+
result2 = json.loads(verbose)
|
|
432
|
+
self.assertEqual(result2["choices"][0]["delta"]["content"], "A")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == '__main__':
|
|
436
|
+
unittest.main()
|