@@ -50,10 +50,13 @@ class OpenAIConfig(TypedDict, total=False):
5050 params: Model parameters (e.g., max_tokens).
5151 For a complete list of supported parameters, see
5252 https://platform.openai.com/docs/api-reference/chat/create.
53+ streaming: Optional flag to indicate whether provider streaming should be used.
54+ If omitted, defaults to True (preserves existing behaviour).
5355 """
5456
5557 model_id : str
5658 params : Optional [dict [str , Any ]]
59+ streaming : bool | None
5760
5861 def __init__ (self , client_args : Optional [dict [str , Any ]] = None , ** model_config : Unpack [OpenAIConfig ]) -> None :
5962 """Initialize provider instance.
@@ -332,7 +335,7 @@ def format_request(
332335 messages , system_prompt , system_prompt_content = system_prompt_content
333336 ),
334337 "model" : self .config ["model_id" ],
335- "stream" : True ,
338+ "stream" : self . config . get ( "streaming" , True ) ,
336339 "stream_options" : {"include_usage" : True },
337340 "tools" : [
338341 {
@@ -422,6 +425,68 @@ def format_chunk(self, event: dict[str, Any], **kwargs: Any) -> StreamEvent:
422425 case _:
423426 raise RuntimeError (f"chunk_type=<{ event ['chunk_type' ]} | unknown type" )
424427
428+ def _convert_non_streaming_to_streaming (self , response : Any ) -> list [StreamEvent ]:
429+ """Convert a provider non-streaming response into streaming-style events.
430+
431+ This helper intentionally *does not* emit the initial message_start/content_start events,
432+ because the caller (stream) already yields them to preserve parity with streaming flow.
433+ """
434+ events : list [StreamEvent ] = []
435+
436+ # Extract main text content from first choice if available
437+ if getattr (response , "choices" , None ):
438+ choice = response .choices [0 ]
439+ content = None
440+ if hasattr (choice , "message" ) and hasattr (choice .message , "content" ):
441+ content = choice .message .content
442+
443+ # handle str content
444+ if isinstance (content , str ):
445+ events .append (self .format_chunk ({"chunk_type" : "content_delta" , "data_type" : "text" , "data" : content }))
446+ # handle list content (list of blocks/dicts)
447+ elif isinstance (content , list ):
448+ for block in content :
449+ if isinstance (block , dict ):
450+ # reasoning content
451+ if "reasoningContent" in block and isinstance (block ["reasoningContent" ], dict ):
452+ try :
453+ text = block ["reasoningContent" ]["reasoningText" ]["text" ]
454+ events .append (
455+ self .format_chunk (
456+ {"chunk_type" : "content_delta" , "data_type" : "reasoning_content" , "data" : text }
457+ )
458+ )
459+ except Exception :
460+ # fall back to keeping the block as text if malformed
461+ pass
462+ # text block
463+ elif "text" in block :
464+ events .append (
465+ self .format_chunk (
466+ {"chunk_type" : "content_delta" , "data_type" : "text" , "data" : block ["text" ]}
467+ )
468+ )
469+ # ignore other block types for now
470+ elif isinstance (block , str ):
471+ events .append (
472+ self .format_chunk ({"chunk_type" : "content_delta" , "data_type" : "text" , "data" : block })
473+ )
474+
475+ # content stop
476+ events .append (self .format_chunk ({"chunk_type" : "content_stop" }))
477+
478+ # message stop — convert finish reason if available
479+ stop_reason = None
480+ if getattr (response , "choices" , None ):
481+ stop_reason = getattr (response .choices [0 ], "finish_reason" , None )
482+ events .append (self .format_chunk ({"chunk_type" : "message_stop" , "data" : stop_reason or "stop" }))
483+
484+ # metadata (usage) if present
485+ if getattr (response , "usage" , None ):
486+ events .append (self .format_chunk ({"chunk_type" : "metadata" , "data" : response .usage }))
487+
488+ return events
489+
425490 @override
426491 async def stream (
427492 self ,
@@ -480,57 +545,71 @@ async def stream(
480545 finish_reason = None # Store finish_reason for later use
481546 event = None # Initialize for scope safety
482547
483- async for event in response :
484- # Defensive: skip events with empty or missing choices
485- if not getattr (event , "choices" , None ):
486- continue
487- choice = event .choices [0 ]
488-
489- if hasattr (choice .delta , "reasoning_content" ) and choice .delta .reasoning_content :
490- chunks , data_type = self ._stream_switch_content ("reasoning_content" , data_type )
491- for chunk in chunks :
492- yield chunk
493- yield self .format_chunk (
494- {
495- "chunk_type" : "content_delta" ,
496- "data_type" : data_type ,
497- "data" : choice .delta .reasoning_content ,
498- }
499- )
500-
501- if choice .delta .content :
502- chunks , data_type = self ._stream_switch_content ("text" , data_type )
503- for chunk in chunks :
504- yield chunk
548+ streaming = self .config .get ("streaming" , True )
549+
550+ if streaming :
551+ # response is an async iterator when streaming=True
552+ async for event in response :
553+ # skip events with empty or missing choices
554+ if not getattr (event , "choices" , None ):
555+ continue
556+ choice = event .choices [0 ]
557+
558+ if hasattr (choice .delta , "reasoning_content" ) and choice .delta .reasoning_content :
559+ chunks , data_type = self ._stream_switch_content ("reasoning_content" , data_type )
560+ for chunk in chunks :
561+ yield chunk
562+ yield self .format_chunk (
563+ {
564+ "chunk_type" : "content_delta" ,
565+ "data_type" : data_type ,
566+ "data" : choice .delta .reasoning_content ,
567+ }
568+ )
569+
570+ if choice .delta .content :
571+ chunks , data_type = self ._stream_switch_content ("text" , data_type )
572+ for chunk in chunks :
573+ yield chunk
574+ yield self .format_chunk (
575+ {"chunk_type" : "content_delta" , "data_type" : data_type , "data" : choice .delta .content }
576+ )
577+
578+ for tool_call in choice .delta .tool_calls or []:
579+ tool_calls .setdefault (tool_call .index , []).append (tool_call )
580+
581+ if choice .finish_reason :
582+ finish_reason = choice .finish_reason # Store for use outside loop
583+ if data_type :
584+ yield self .format_chunk ({"chunk_type" : "content_stop" , "data_type" : data_type })
585+ break
586+
587+ for tool_deltas in tool_calls .values ():
505588 yield self .format_chunk (
506- {"chunk_type" : "content_delta " , "data_type" : data_type , "data" : choice . delta . content }
589+ {"chunk_type" : "content_start " , "data_type" : "tool" , "data" : tool_deltas [ 0 ] }
507590 )
508591
509- for tool_call in choice .delta .tool_calls or []:
510- tool_calls .setdefault (tool_call .index , []).append (tool_call )
511-
512- if choice .finish_reason :
513- finish_reason = choice .finish_reason # Store for use outside loop
514- if data_type :
515- yield self .format_chunk ({"chunk_type" : "content_stop" , "data_type" : data_type })
516- break
517-
518- for tool_deltas in tool_calls .values ():
519- yield self .format_chunk ({"chunk_type" : "content_start" , "data_type" : "tool" , "data" : tool_deltas [0 ]})
520-
521- for tool_delta in tool_deltas :
522- yield self .format_chunk ({"chunk_type" : "content_delta" , "data_type" : "tool" , "data" : tool_delta })
592+ for tool_delta in tool_deltas :
593+ yield self .format_chunk (
594+ {"chunk_type" : "content_delta" , "data_type" : "tool" , "data" : tool_delta }
595+ )
523596
524- yield self .format_chunk ({"chunk_type" : "content_stop" , "data_type" : "tool" })
597+ yield self .format_chunk ({"chunk_type" : "content_stop" , "data_type" : "tool" })
525598
526- yield self .format_chunk ({"chunk_type" : "message_stop" , "data" : finish_reason or "end_turn" })
599+ yield self .format_chunk ({"chunk_type" : "message_stop" , "data" : finish_reason or "end_turn" })
527600
528- # Skip remaining events as we don't have use for anything except the final usage payload
529- async for event in response :
530- _ = event
601+ # Skip remaining events
602+ async for event in response :
603+ _ = event
531604
532- if event and hasattr (event , "usage" ) and event .usage :
533- yield self .format_chunk ({"chunk_type" : "metadata" , "data" : event .usage })
605+ if event and hasattr (event , "usage" ) and event .usage :
606+ yield self .format_chunk ({"chunk_type" : "metadata" , "data" : event .usage })
607+ else :
608+ # Non-streaming provider response — convert to streaming-style events.
609+ # We manually emit the content_start event here to align with the streaming path
610+ yield self .format_chunk ({"chunk_type" : "content_start" , "data_type" : "text" })
611+ for ev in self ._convert_non_streaming_to_streaming (response ):
612+ yield ev
534613
535614 logger .debug ("finished streaming response from model" )
536615
0 commit comments