@@ -167,7 +167,11 @@ async def _handle_request(self, request: web.Request) -> web.Response:
167167 proxy = self .http_proxy ,
168168 ) as resp :
169169 if is_stream :
170- # Stream response: collect chunks, forward as-is
170+ # Stream response: collect chunks, forward as-is.
171+ # If the downstream client disconnects mid-stream (e.g. an
172+ # AsyncAPIClient bails out on a finish_reason=='error' chunk
173+ # after the prompt overflowed the session), keep draining
174+ # the upstream so the trace is still recorded in full.
171175 response_chunks = []
172176 response = web .StreamResponse (
173177 status = resp .status ,
@@ -178,10 +182,19 @@ async def _handle_request(self, request: web.Request) -> web.Response:
178182 },
179183 )
180184 await response .prepare (request )
185+ client_alive = True
181186 async for chunk in resp .content .iter_any ():
182187 response_chunks .append (chunk )
183- await response .write (chunk )
184- await response .write_eof ()
188+ if client_alive :
189+ try :
190+ await response .write (chunk )
191+ except (ConnectionError , aiohttp .ClientConnectionResetError ):
192+ client_alive = False
193+ if client_alive :
194+ try :
195+ await response .write_eof ()
196+ except (ConnectionError , aiohttp .ClientConnectionResetError ):
197+ pass
185198 raw_response = b'' .join (response_chunks )
186199 else :
187200 raw_response = await resp .read ()
@@ -206,15 +219,15 @@ async def _handle_request(self, request: web.Request) -> web.Response:
206219 except (json .JSONDecodeError , UnicodeDecodeError ):
207220 pass
208221
209- # Additional fallback for Anthropic HTTP API error responses wrapped in proper JSON
222+ # Upstream returned an error body — forward it to the client
223+ # untouched (so the agent sees the real error), but treat
224+ # response as invalid so we don't record garbage.
210225 if response_data and response_data .get ('type' ) == 'error' :
211- logger .error (f"Received error from provider: { response_data } " )
212- raise RuntimeError (f"Provider returned error: { response_data } " )
213-
214- # Additional fallback for OpenAI HTTP API error responses
215- if response_data and 'error' in response_data and isinstance (response_data ['error' ], dict ):
216- logger .error (f"Received error from provider: { response_data } " )
217- raise RuntimeError (f"Provider returned error: { response_data } " )
226+ logger .warning (f"Anthropic-format error from upstream: { response_data } " )
227+ response_data = None
228+ elif response_data and isinstance (response_data .get ('error' ), dict ):
229+ logger .warning (f"OpenAI-format error from upstream: { response_data } " )
230+ response_data = None
218231
219232 # 7. Record
220233 has_messages = isinstance (request_data , dict ) and 'messages' in request_data
@@ -290,6 +303,15 @@ async def _handle_request(self, request: web.Request) -> web.Response:
290303 messages .extend (response_items )
291304 elif assistant_msg :
292305 messages .append (assistant_msg )
306+ else :
307+ # Response invalid (parser returned None, error body, no
308+ # assistant content, etc.). Mirror model.py: drop the
309+ # whole input+output rather than recording an orphaned
310+ # user-only turn.
311+ logger .debug (
312+ f"Skipping record for session { self .session_id } : no valid response"
313+ )
314+ return response
293315
294316 # Convention: function_call.arguments stored as dict (not JSON
295317 # string). Applied to all items uniformly — including input items
@@ -321,17 +343,29 @@ async def _handle_request(self, request: web.Request) -> web.Response:
321343 def _parse_stream_response (raw : bytes ) -> Optional [dict ]:
322344 """Parse SSE stream response to reconstruct the complete message.
323345
324- Supports both Anthropic and OpenAI streaming formats.
346+ Supports both Anthropic and OpenAI streaming formats. Returns
347+ ``None`` if the stream is invalid (malformed SSE JSON, no
348+ events, or format-specific failure modes detected by the
349+ per-format parsers).
325350 """
326351 text = raw .decode ('utf-8' , errors = 'replace' )
327- events = []
352+ events : list = []
353+ saw_done = False
354+
328355 for line in text .split ('\n ' ):
329356 line = line .strip ()
330- if line .startswith ('data: ' ) and line != 'data: [DONE]' :
331- try :
332- events .append (json .loads (line [6 :]))
333- except json .JSONDecodeError :
334- pass
357+ if not line .startswith ('data: ' ):
358+ continue
359+ if line == 'data: [DONE]' :
360+ saw_done = True
361+ continue
362+ try :
363+ events .append (json .loads (line [6 :]))
364+ except json .JSONDecodeError :
365+ # Mirrors model.py: malformed SSE JSON invalidates the
366+ # whole stream rather than getting silently skipped.
367+ logger .warning (f"Invalid SSE JSON in stream: { line [6 :][:200 ]} " )
368+ return None
335369
336370 if not events :
337371 return None
@@ -343,49 +377,68 @@ def _parse_stream_response(raw: bytes) -> Optional[dict]:
343377 if evt_type .startswith ('response.' ) or first .get ('object' ) == 'response' :
344378 return SessionClient ._parse_responses_stream (events )
345379 if 'choices' in first or first .get ('object' ) == 'chat.completion.chunk' :
346- return SessionClient ._parse_openai_stream (events )
380+ return SessionClient ._parse_openai_stream (events , saw_done = saw_done )
347381 return SessionClient ._parse_anthropic_stream (events )
348382
349383 @staticmethod
350- def _parse_openai_stream (events : list ) -> Optional [dict ]:
351- """Reconstruct OpenAI chat completion from stream chunks."""
352- message = {
353- 'choices' : [{'message' : {'role' : 'assistant' , 'content' : '' }}],
384+ def _parse_openai_stream (events : list , saw_done : bool = False ) -> Optional [dict ]:
385+ """Reconstruct OpenAI chat completion from stream chunks.
386+
387+ Validation mirrors ``AsyncAPIClient.chat`` in ``model.py``:
388+ returns ``None`` if any of these signals an invalid stream:
389+
390+ - mid-stream error event (``error`` field, ``type=='error'``,
391+ ``object=='error'``)
392+ - ``finish_reason == 'error'`` (except input-length errors,
393+ which carry useful trailing content and are kept)
394+ - no ``choices`` ever observed (``saw_choice``)
395+ - no ``data: [DONE]`` terminator (``saw_done``)
396+ - no terminal ``finish_reason`` on choices[0]
397+ """
398+ message : Dict = {
399+ 'choices' : [{'message' : {'role' : 'assistant' , 'content' : '' }, 'finish_reason' : None }],
354400 }
355- content_parts = []
356- reasoning_content_parts = []
357- tool_calls_map : Dict [int , dict ] = {} # index → {id, type, function}
358- usage = {}
401+ content_parts : List [str ] = []
402+ reasoning_content_parts : List [str ] = []
403+ tool_calls_map : Dict [int , dict ] = {}
404+ function_call_data : Optional [dict ] = None
405+ usage : dict = {}
406+ saw_choice = False
359407
360408 for event in events :
409+ # In-stream error event — invalidate the whole trace.
410+ if (
411+ event .get ('error' ) is not None
412+ or event .get ('type' ) == 'error'
413+ or event .get ('object' ) == 'error'
414+ ):
415+ logger .warning (f"OpenAI stream error event: { event } " )
416+ return None
417+
361418 if event .get ('id' ) and 'id' not in message :
362419 message ['id' ] = event ['id' ]
363420 if event .get ('model' ):
364421 message ['model' ] = event ['model' ]
365422
366423 choices = event .get ('choices' , [])
424+ if choices :
425+ saw_choice = True
426+
367427 for choice in choices :
368428 delta = choice .get ('delta' , {})
369429
370- # Content
371430 if delta .get ('content' ):
372431 content_parts .append (delta ['content' ])
373-
374- # Reasoning Content
375432 if delta .get ('reasoning_content' ):
376433 reasoning_content_parts .append (delta ['reasoning_content' ])
377434
378- # Tool calls
379435 for tc_delta in delta .get ('tool_calls' ) or []:
380436 idx = tc_delta .get ('index' , 0 )
381437 if idx not in tool_calls_map :
382438 tool_calls_map [idx ] = {
383439 'id' : tc_delta .get ('id' , '' ),
384440 'type' : tc_delta .get ('type' , 'function' ),
385- 'function' : {
386- 'name' : '' ,
387- 'arguments' : '' ,
388- },
441+ 'function' : {'name' : '' , 'arguments' : '' },
389442 }
390443 tc = tool_calls_map [idx ]
391444 fn = tc_delta .get ('function' , {})
@@ -396,18 +449,46 @@ def _parse_openai_stream(events: list) -> Optional[dict]:
396449 if tc_delta .get ('id' ):
397450 tc ['id' ] = tc_delta ['id' ]
398451
452+ fc_delta = delta .get ('function_call' )
453+ if fc_delta :
454+ if function_call_data is None :
455+ function_call_data = {'name' : '' , 'arguments' : '' }
456+ if fc_delta .get ('name' ):
457+ function_call_data ['name' ] += fc_delta ['name' ]
458+ if fc_delta .get ('arguments' ):
459+ function_call_data ['arguments' ] += fc_delta ['arguments' ]
460+
399461 if choice .get ('finish_reason' ):
400462 message ['choices' ][0 ]['finish_reason' ] = choice ['finish_reason' ]
463+ if choice ['finish_reason' ] == 'error' :
464+ logger .warning (f"OpenAI finish_reason=error: { event } " )
465+ return None
401466
402467 if event .get ('usage' ):
403468 usage = event ['usage' ]
404469
470+ # Stream completeness checks (mirror model.py).
471+ if not saw_choice :
472+ logger .warning ("OpenAI stream ended without any choices" )
473+ return None
474+ if not saw_done :
475+ logger .warning ("OpenAI stream ended without [DONE] terminator" )
476+ return None
477+ if not message ['choices' ][0 ].get ('finish_reason' ):
478+ logger .warning ("OpenAI stream ended without terminal finish_reason" )
479+ return None
480+
405481 msg = message ['choices' ][0 ]['message' ]
406482 msg ['content' ] = '' .join (content_parts )
407483 if reasoning_content_parts :
408484 msg ['reasoning_content' ] = '' .join (reasoning_content_parts )
409485 if tool_calls_map :
410- msg ['tool_calls' ] = [tool_calls_map [i ] for i in sorted (tool_calls_map )]
486+ # Keep arguments as-string here (matching the non-stream raw
487+ # API shape). _handle_request does the single point of
488+ # JSON-deserialization for both stream and non-stream paths.
489+ msg ['tool_calls' ] = [tool_calls_map [idx ] for idx in sorted (tool_calls_map )]
490+ if function_call_data :
491+ msg ['function_call' ] = function_call_data
411492 if usage :
412493 message ['usage' ] = usage
413494 return message
@@ -416,15 +497,21 @@ def _parse_openai_stream(events: list) -> Optional[dict]:
416497 def _parse_responses_stream (events : list ) -> Optional [dict ]:
417498 """Reconstruct an OpenAI Responses API result from stream events.
418499
419- Prefers the terminal ``response.completed`` / ``response.failed`` /
420- ``response.incomplete `` event, which carries the full response object.
421- Falls back to incremental reconstruction from delta events when no
422- terminal event was seen (e.g. truncated stream) .
500+ Prefers the terminal ``response.completed`` event (carries the
501+ full response object). ``response.failed `` / `` response.incomplete``
502+ and any ``error`` event invalidate the trace — returns ``None``
503+ so the caller skips recording .
423504 """
424- # Fast path: terminal events embed the full response object
425- for event in reversed ( events ) :
505+ # Reject explicit failure / incomplete terminals.
506+ for event in events :
426507 evt_type = event .get ('type' , '' )
427- if evt_type in ('response.completed' , 'response.failed' , 'response.incomplete' ):
508+ if evt_type in ('response.failed' , 'response.incomplete' , 'error' ):
509+ logger .warning (f"Responses stream terminal failure: { evt_type } " )
510+ return None
511+
512+ # Fast path: completed event embeds the full response object
513+ for event in reversed (events ):
514+ if event .get ('type' ) == 'response.completed' :
428515 resp = event .get ('response' )
429516 if resp is not None :
430517 return resp
@@ -543,14 +630,23 @@ def _parse_responses_stream(events: list) -> Optional[dict]:
543630
544631 @staticmethod
545632 def _parse_anthropic_stream (events : list ) -> Optional [dict ]:
546- """Reconstruct Anthropic message from stream events."""
547- message = {}
548- content_blocks = []
549- current_block = {}
633+ """Reconstruct Anthropic message from stream events.
634+
635+ Returns ``None`` if an ``error`` event is observed mid-stream
636+ or no ``message_stop`` is seen.
637+ """
638+ message : Dict = {}
639+ content_blocks : List [dict ] = []
640+ current_block : dict = {}
641+ saw_message_stop = False
550642
551643 for event in events :
552644 event_type = event .get ('type' , '' )
553645
646+ if event_type == 'error' :
647+ logger .warning (f"Anthropic stream error event: { event } " )
648+ return None
649+
554650 if event_type == 'message_start' :
555651 # Initial message metadata
556652 msg = event .get ('message' , {})
@@ -610,6 +706,13 @@ def _parse_anthropic_stream(events: list) -> Optional[dict]:
610706 else :
611707 message ['usage' ][k ] = v
612708
709+ elif event_type == 'message_stop' :
710+ saw_message_stop = True
711+
712+ if not saw_message_stop :
713+ logger .warning ("Anthropic stream ended without message_stop" )
714+ return None
715+
613716 # Assemble final message
614717 message ['content' ] = content_blocks
615718 return message
0 commit comments