11from __future__ import annotations
22
33import base64
4+ import binascii
45import json
6+ import re
57from typing import AsyncIterable , AsyncIterator , Dict , Iterable , Iterator , List , Optional
68
79from sonilo .errors import GenerationError
810from sonilo .types import StreamEvent , Track
911
1012
11- def _parse_line (line : str ) -> StreamEvent :
12- event = json .loads (line )
13+ def _forgiving_b64decode (data : str ) -> bytes :
14+ """Decode base64 the way the JS SDK's `atob()` does: WHATWG's
15+ "forgiving-base64 decode" algorithm (https://infra.spec.whatwg.org/#forgiving-base64-decode).
16+
17+ This differs from `base64.b64decode` in two ways that matter for
18+ cross-SDK parity on the same wire payload:
19+ - Padding is optional. `atob()` does not require `=` padding, so an
20+ upstream re-encoding that omits it must still decode instead of
21+ spuriously failing a generation the JS SDK handles fine.
22+ - Only ASCII whitespace (space, tab, LF, FF, CR) is stripped before
23+ decoding, not all Unicode whitespace (`atob()` throws on NBSP,
24+ vertical tab, U+2028, etc., so we must reject those too, not
25+ silently strip them).
26+ """
27+ cleaned = re .sub (r"[ \t\n\f\r]" , "" , data )
28+ if len (cleaned ) % 4 == 0 and cleaned .endswith ("=" ):
29+ cleaned = cleaned [:- 2 ] if cleaned .endswith ("==" ) else cleaned [:- 1 ]
30+ if len (cleaned ) % 4 == 1 :
31+ raise binascii .Error (
32+ "Invalid base64-encoded string: number of data characters cannot be 1 more "
33+ "than a multiple of 4"
34+ )
35+ if not re .fullmatch (r"[A-Za-z0-9+/]*" , cleaned ):
36+ raise binascii .Error ("Non-base64 digit found" )
37+ padded = cleaned + "=" * (- len (cleaned ) % 4 )
38+ return base64 .b64decode (padded , validate = True )
39+
40+
41+ def _parse_line (line : str ) -> Optional [StreamEvent ]:
42+ """Returns `None` for a valid-JSON-but-non-dict line (e.g. a bare `null`
43+ or a number/string), which carries no event `type` and is skipped like
44+ any other junk line rather than crashing on a `.get()` off `None`."""
45+ parsed = json .loads (line )
46+ if not isinstance (parsed , dict ):
47+ return None
48+ event = parsed
1349 if event .get ("type" ) == "audio_chunk" and isinstance (event .get ("data" ), str ):
14- event = {** event , "data" : base64 .b64decode (event ["data" ])}
50+ try :
51+ # Mirror the JS SDK's `atob()` (WHATWG forgiving-base64) exactly,
52+ # via _forgiving_b64decode: unpadded base64 must decode (not
53+ # raise), only ASCII whitespace is stripped (not all Unicode
54+ # whitespace), and an invalid-alphabet or misaligned-length
55+ # payload must still raise so it doesn't silently decode to
56+ # fewer, wrong bytes.
57+ decoded = _forgiving_b64decode (event ["data" ])
58+ event = {** event , "data" : decoded }
59+ except (binascii .Error , ValueError ):
60+ # Don't raise here: this must reach _TrackBuilder.add, whose
61+ # malformed-chunk check turns undecodable data into a typed
62+ # GenerationError. Raising in place would let a raw
63+ # binascii.Error/ValueError escape stream()/generate(), breaking
64+ # the SDK's "all errors extend SoniloError" contract.
65+ pass
1566 return event
1667
1768
@@ -29,12 +80,16 @@ def feed(self, text: str) -> Iterator[StreamEvent]:
2980 return
3081 line , self ._buf = self ._buf [:idx ].strip (), self ._buf [idx + 1 :]
3182 if line :
32- yield _parse_line (line )
83+ event = _parse_line (line )
84+ if event is not None :
85+ yield event
3386
3487 def flush (self ) -> Iterator [StreamEvent ]:
3588 line , self ._buf = self ._buf .strip (), ""
3689 if line :
37- yield _parse_line (line )
90+ event = _parse_line (line )
91+ if event is not None :
92+ yield event
3893
3994
4095def iter_events (text_chunks : Iterable [str ]) -> Iterator [StreamEvent ]:
@@ -62,16 +117,29 @@ def __init__(self) -> None:
62117
63118 def add (self , event : StreamEvent ) -> None :
64119 event_type = event .get ("type" )
65- if event_type == "audio_chunk" and isinstance (event .get ("data" ), bytes ):
120+ if event_type == "audio_chunk" :
121+ # A malformed chunk (missing/non-decodable `data`) must not be
122+ # silently dropped: that would hand back a "successful" Track
123+ # with empty or truncated audio and no indication anything went
124+ # wrong.
125+ if not isinstance (event .get ("data" ), bytes ):
126+ raise GenerationError (
127+ "received a malformed audio_chunk event (missing or non-decodable data)"
128+ )
66129 self ._chunks .append (event ["data" ])
67130 elif event_type == "title" and isinstance (event .get ("title" ), str ):
68131 self ._title = event ["title" ]
69132 elif event_type == "cost" :
70133 self ._cost = {k : v for k , v in event .items () if k != "type" }
71134 elif event_type == "error" :
72- message = event .get ("message" ) or "generation failed"
135+ raw_message = event .get ("message" )
136+ message = (
137+ raw_message
138+ if isinstance (raw_message , str ) and raw_message
139+ else "generation failed"
140+ )
73141 code = event .get ("code" )
74- raise GenerationError (str ( message ) , code = code if isinstance (code , str ) else None )
142+ raise GenerationError (message , code = code if isinstance (code , str ) else None )
75143 elif event_type == "complete" :
76144 self ._complete = True
77145 # unknown event types: ignored
0 commit comments