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 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
- chunk = line[5:].strip()
188
- if chunk == "[DONE]":
189
- break
190
- try:
191
- json_data = json.loads(chunk)
192
- if (
193
- json_data.get("choices")
194
- and len(json_data["choices"]) > 0
195
- and "delta" in json_data["choices"][0]
196
- and "content" in json_data["choices"][0]["delta"]
197
- ):
198
- content = json_data["choices"][0]["delta"]["content"]
199
- yield content
200
- except JSONDecodeError:
201
- continue
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
- chunk = line[5:].strip()
216
- if chunk == "[DONE]":
217
- break
218
- try:
219
- json_data = json.loads(chunk)
220
- if (
221
- json_data.get("choices")
222
- and len(json_data["choices"]) > 0
223
- and "delta" in json_data["choices"][0]
224
- and "content" in json_data["choices"][0]["delta"]
225
- ):
226
- content = json_data["choices"][0]["delta"]["content"]
227
- yield content
228
- except JSONDecodeError:
229
- continue
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
- error_found = False
9
- if (
10
- "errors" in response or
11
- "error" in response or
12
- (
13
- "message" in response and
14
- isinstance(response.get("message"), list) and
15
- len(response.get("message")) > 0 and
16
- response.get("message")[0].get("type") == "error"
17
- )
18
- ):
19
- error_found = True
20
-
21
- return error_found
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:
@@ -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
- full_msg = f"Unable to {operation}"
21
- if context:
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
- logger.error(f"{full_msg}: JSON parsing error (status {response.status_code}): {e}. Response: {response.text}")
32
- raise InvalidAPIResponseException(f"{full_msg}: {response.text}")
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()