Coverage for src / lexigram / ai / relay / gateway / stream.py: 94%

145 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-08 23:08 +0800

1"""Upstream streaming framing, cancellation, and session lifecycle. 

2 

3The gateway parses framing only and holds NO accumulated text or tool 

4arguments: each ``UpstreamChunk`` is decoded into a source DTO and 

5forwarded to the stateful ``RelayStreamSessionProtocol``, which owns any 

6partial content. The terminal flag is set on the final event of each 

7finalize batch, and upstream cancellation plus session finalization each 

8happen at most once. 

9""" 

10 

11from __future__ import annotations 

12 

13import asyncio 

14from collections.abc import AsyncIterator 

15from dataclasses import dataclass 

16from typing import Any, cast 

17 

18from lexigram.contracts.ai.exceptions import RelayError 

19from lexigram.contracts.ai.relay import ( 

20 RelayFormat, 

21 RelayGatewayError, 

22 RelayStreamSessionProtocol, 

23 RelayUpstreamProtocol, 

24 RelayWireEvent, 

25 UpstreamChunk, 

26 UpstreamRequest, 

27) 

28from lexigram.contracts.ai.relay.dto import ( 

29 ClaudeStreamEvent, 

30 GeminiResponse, 

31 OpenAIChatStreamChunk, 

32 ResponsesEvent, 

33) 

34from lexigram.contracts.ai.relay.gateway import RelayGatewayErrorCode 

35from lexigram.logging import get_logger 

36from lexigram.serialization import loads 

37 

38__all__ = ["UpstreamEventParser", "relay_stream"] 

39 

40logger = get_logger(__name__) 

41 

42 

43@dataclass(frozen=True, slots=True) 

44class _Parsed: 

45 """Outcome of framing one upstream chunk.""" 

46 

47 events: tuple[Any, ...] 

48 terminal: bool = False 

49 error: bool = False 

50 

51 

52class UpstreamEventParser: 

53 """Frame upstream chunks into target session events. 

54 

55 The parser decodes one chunk at a time, classifies it (keepalive, 

56 delta, terminal, or error), and forwards source DTOs to the injected 

57 session. It never accumulates text or tool arguments. 

58 """ 

59 

60 def __init__( 

61 self, 

62 session: RelayStreamSessionProtocol, 

63 source: RelayFormat, 

64 *, 

65 request_id: str, 

66 ) -> None: 

67 """Bind the parser to a session and source wire format. 

68 

69 Args: 

70 session: Stateful stream session accepting source DTOs and 

71 emitting target events. 

72 source: Wire format of the upstream stream. 

73 request_id: Id stamped on the malformed-stream errors this 

74 parser raises. 

75 """ 

76 self._session = session 

77 self._source = source 

78 self._request_id = request_id 

79 self._finalized = False 

80 self._finalize_cache: tuple[Any, ...] = () 

81 self.finalized = False 

82 self.truncated = False 

83 self.cancelled = False 

84 

85 def parse(self, chunk: UpstreamChunk) -> _Parsed: 

86 """Frame one upstream chunk into target session events. 

87 

88 Args: 

89 chunk: One raw upstream frame. 

90 

91 Returns: 

92 The framed outcome: emitted target events plus terminal and 

93 error classification. A transport-level ``terminal=True`` 

94 chunk short-circuits decoding. 

95 

96 Raises: 

97 RelayGatewayError: With code ``UPSTREAM_MALFORMED`` (502, 

98 never retryable) when the payload is malformed JSON, 

99 fails DTO validation, or is rejected by the session. 

100 """ 

101 if chunk.terminal: 

102 return _Parsed(()) 

103 if self._source == RelayFormat.OPENAI_CHAT: 

104 return self._parse_openai_chat(chunk) 

105 if self._source == RelayFormat.OPENAI_RESPONSES: 

106 return self._parse_openai_responses(chunk) 

107 if self._source == RelayFormat.CLAUDE: 

108 return self._parse_claude(chunk) 

109 return self._parse_gemini(chunk) 

110 

111 def finalize(self) -> tuple[Any, ...]: 

112 """Close the session deterministically exactly once. 

113 

114 The first call runs the session finalize and caches its events; 

115 subsequent calls return the cached result without touching the 

116 session again. 

117 

118 Returns: 

119 The session's terminal events. 

120 """ 

121 if self._finalized: 

122 return self._finalize_cache 

123 self._finalized = True 

124 self._finalize_cache = self._session.finalize() 

125 return self._finalize_cache 

126 

127 def _parse_openai_chat(self, chunk: UpstreamChunk) -> _Parsed: 

128 """Frame an OpenAI Chat chunk, honoring keepalives and ``[DONE]``.""" 

129 data = chunk.data.strip() 

130 if not data: 

131 return _Parsed(()) 

132 if data == "[DONE]": 

133 return _Parsed((), terminal=True) 

134 return self._accept(self._decode(OpenAIChatStreamChunk, data)) 

135 

136 def _parse_openai_responses(self, chunk: UpstreamChunk) -> _Parsed: 

137 """Frame an OpenAI Responses chunk by its ``type`` discriminator.""" 

138 if not chunk.data.strip(): 

139 return _Parsed(()) 

140 dto = self._decode(ResponsesEvent, chunk.data) 

141 if dto.type == "response.completed": 

142 return _Parsed((), terminal=True) 

143 if dto.type in {"response.error", "response.failed", "response.incomplete"}: 

144 return _Parsed((), error=True) 

145 return self._accept(dto) 

146 

147 def _parse_claude(self, chunk: UpstreamChunk) -> _Parsed: 

148 """Frame a Claude chunk by its ``type`` discriminator.""" 

149 if not chunk.data.strip(): 

150 return _Parsed(()) 

151 dto = self._decode(ClaudeStreamEvent, chunk.data) 

152 if dto.type == "ping": 

153 return _Parsed(()) 

154 if dto.type == "message_stop": 

155 return _Parsed((), terminal=True) 

156 if dto.type == "error": 

157 return _Parsed((), error=True) 

158 return self._accept(dto) 

159 

160 def _parse_gemini(self, chunk: UpstreamChunk) -> _Parsed: 

161 """Frame a Gemini NDJSON line; never terminal or error.""" 

162 if not chunk.data.strip(): 

163 return _Parsed(()) 

164 return self._accept(self._decode(GeminiResponse, chunk.data)) 

165 

166 def _decode(self, dto_type: type[Any], data: str) -> Any: 

167 """Decode a JSON string into a wire DTO, mapping errors safely.""" 

168 try: 

169 decoded = loads(data) 

170 except (TypeError, ValueError) as error: 

171 raise self._malformed(None) from error 

172 if not isinstance(decoded, dict): 

173 raise self._malformed(None) 

174 try: 

175 return dto_type.from_dict(decoded) 

176 except RelayError as error: 

177 raise self._malformed(error) from error 

178 

179 def _accept(self, dto: Any) -> _Parsed: 

180 """Forward a DTO to the session, mapping session errors safely.""" 

181 try: 

182 events = self._session.accept(dto) 

183 except RelayError as error: 

184 raise self._malformed(error) from error 

185 return _Parsed(events) 

186 

187 def _malformed(self, error: RelayError | None) -> RelayGatewayError: 

188 """Build the malformed-stream error from a safe public message.""" 

189 message = error.message if error is not None else "malformed upstream chunk" 

190 return RelayGatewayError( 

191 code=RelayGatewayErrorCode.UPSTREAM_MALFORMED, 

192 message=message, 

193 status_code=502, 

194 request_id=self._request_id, 

195 retryable=False, 

196 ) 

197 

198 

199def _wire_events(events: tuple[Any, ...], terminal: bool) -> tuple[RelayWireEvent, ...]: 

200 """Frame target DTOs as wire events, flagging the last as terminal. 

201 

202 Args: 

203 events: Target DTOs to frame. 

204 terminal: Whether the final event should carry ``terminal=True``. 

205 

206 Returns: 

207 A ``RelayWireEvent`` tuple re-emitted from the target DTOs. 

208 """ 

209 return tuple( 

210 RelayWireEvent( 

211 event=getattr(event, "type", None), 

212 data=event.to_dict(), 

213 terminal=terminal and index == len(events) - 1, 

214 ) 

215 for index, event in enumerate(events) 

216 ) 

217 

218 

219async def relay_stream( 

220 upstream: RelayUpstreamProtocol, 

221 request: UpstreamRequest, 

222 parser: UpstreamEventParser, 

223 cancel_handle: asyncio.Event | None = None, 

224) -> AsyncIterator[RelayWireEvent]: 

225 """Relay one upstream stream with cancellation and session lifecycle. 

226 

227 The ``async for`` inside this generator consumes exactly one upstream 

228 chunk per consumer ``__anext__``: backpressure is inherent and there 

229 is no buffering or prefetch. 

230 

231 Lifecycle: terminal frames finalize the session with no cancellation; 

232 error frames and consumer disconnects cancel upstream once and 

233 finalize truncated; streams that end without a terminal marker (for 

234 example Gemini or a cut SSE stream) finalize truncated without 

235 cancelling. Upstream ``cancel`` and session ``finalize`` each run at 

236 most once, even across nested exception paths. 

237 

238 Args: 

239 upstream: The upstream transport implementing 

240 ``RelayUpstreamProtocol``. 

241 request: The fully-resolved upstream request. 

242 parser: Stateful session parser whose bookkeeping attributes 

243 (``finalized``, ``truncated``, ``cancelled``) track the stream. 

244 cancel_handle: Optional operator cancel handle from the stream 

245 registry. When set, the relay cancels upstream once and 

246 finalizes truncated at the next chunk boundary. 

247 

248 Yields: 

249 Normalized ``RelayWireEvent`` values; terminal flag on the last 

250 event of each finalize batch. 

251 

252 Raises: 

253 RelayGatewayError: Malformed upstream framing or a session 

254 rejection (502, never retryable). 

255 asyncio.CancelledError: Upstream or consumer task cancellation; 

256 always re-raised. 

257 GeneratorExit: The consumer closed the generator mid-stream. 

258 """ 

259 cancel_guard = False 

260 

261 async def cancel_once() -> None: 

262 """Request upstream cancellation exactly once ever.""" 

263 nonlocal cancel_guard 

264 if not cancel_guard: 

265 cancel_guard = True 

266 parser.cancelled = True 

267 await upstream.cancel(request.request_id) 

268 logger.info( 

269 "relay_gateway_stream_cancelled", 

270 request_id=request.request_id, 

271 ) 

272 

273 def finalize_once(truncated: bool) -> tuple[Any, ...]: 

274 """Finalize the session once, remembering the truncation choice.""" 

275 if not parser.finalized: 

276 parser.finalized = True 

277 parser.truncated = truncated 

278 return parser.finalize() 

279 return () 

280 

281 saw_terminal = False 

282 saw_error = False 

283 try: 

284 stream_iter = cast("AsyncIterator[UpstreamChunk]", upstream.stream(request)) 

285 async for chunk in stream_iter: 

286 if cancel_handle is not None and cancel_handle.is_set(): 

287 await cancel_once() 

288 finalize_once(truncated=True) 

289 break 

290 try: 

291 peaked = parser.parse(chunk) 

292 except ( 

293 RelayGatewayError, 

294 RelayError, 

295 asyncio.CancelledError, 

296 GeneratorExit, 

297 ): 

298 await cancel_once() 

299 finalize_once(truncated=True) 

300 raise 

301 saw_terminal = saw_terminal or peaked.terminal 

302 saw_error = saw_error or peaked.error 

303 for wire in _wire_events(peaked.events, False): 

304 yield wire 

305 except (asyncio.CancelledError, GeneratorExit): 

306 await cancel_once() 

307 finalize_once(truncated=True) 

308 raise 

309 if saw_terminal: 

310 for wire in _wire_events(finalize_once(truncated=False), True): 

311 yield wire 

312 return 

313 if saw_error: 

314 await cancel_once() 

315 for wire in _wire_events(finalize_once(truncated=True), True): 

316 yield wire 

317 return 

318 for wire in _wire_events(finalize_once(truncated=True), True): 

319 yield wire