Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/pass_through_endpoints/streaming_handler.py: 20%

177 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1import traceback 

2from collections.abc import Coroutine, Sequence 

3from dataclasses import dataclass 

4from datetime import datetime, timezone 

5from typing import Final, Protocol 

6 

7import httpx 

8 

9import litellm 

10from litellm._logging import verbose_proxy_logger 

11from litellm.litellm_core_utils.asyncify import asyncify 

12from litellm.litellm_core_utils.core_helpers import bind_budget_reservation_to_callbacks 

13from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

14from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER 

15from litellm.proxy._types import PassThroughEndpointLoggingResultValues 

16from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing 

17from litellm.proxy.common_utils.sse_keepalive import split_complete_sse_frames 

18from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType 

19from litellm.types.utils import StandardPassThroughResponseObject, Usage 

20 

21from .llm_provider_handlers.anthropic_passthrough_logging_handler import ( 

22 AnthropicPassthroughLoggingHandler, 

23) 

24from .llm_provider_handlers.gemini_passthrough_logging_handler import ( 

25 GeminiPassthroughLoggingHandler, 

26) 

27from .llm_provider_handlers.openai_passthrough_logging_handler import ( 

28 OpenAIPassthroughLoggingHandler, 

29) 

30from .llm_provider_handlers.tinyfish_passthrough_logging_handler import ( 

31 TinyFishPassthroughLoggingHandler, 

32 run_id_from_sse_frames, 

33 sse_poller_spawned, 

34) 

35from .llm_provider_handlers.vertex_passthrough_logging_handler import ( 

36 VertexPassthroughLoggingHandler, 

37) 

38from .success_handler import PassThroughEndpointLogging 

39 

40 

41class RouteStreamingLogging(Protocol): 

42 def __call__( 42 ↛ exitline 42 didn't return from function '__call__' because

43 self, 

44 *, 

45 litellm_logging_obj: LiteLLMLoggingObj, 

46 passthrough_success_handler_obj: PassThroughEndpointLogging, 

47 url_route: str, 

48 request_body: dict, 

49 endpoint_type: EndpointType, 

50 start_time: datetime, 

51 raw_bytes: list[bytes], 

52 end_time: datetime, 

53 ) -> Coroutine[None, None, None]: ... 

54 

55 

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

57class PassThroughStreamContext: 

58 passthrough_success_handler_obj: PassThroughEndpointLogging 

59 url_route: str 

60 start_time: datetime 

61 

62 

63class PassThroughStreamingHandler: 

64 @staticmethod 

65 def _stamp_first_chunk_if_needed(litellm_logging_obj: LiteLLMLoggingObj) -> None: 

66 if litellm_logging_obj.completion_start_time is None: 

67 litellm_logging_obj._update_completion_start_time(completion_start_time=datetime.now()) 

68 

69 @staticmethod 

70 async def schedule_stream_failure_logging( 

71 litellm_logging_obj: LiteLLMLoggingObj, 

72 endpoint_type: EndpointType, 

73 request_body: dict[str, object], 

74 raw_bytes: Sequence[bytes], 

75 exception: Exception, 

76 stream_context: PassThroughStreamContext | None = None, 

77 ) -> None: 

78 # the tinyfish poller writes the one authoritative row; a failure row here would collide on its request_id 

79 if endpoint_type == EndpointType.TINYFISH and sse_poller_spawned(litellm_logging_obj): 

80 return 

81 await asyncify(PassThroughStreamingHandler._record_partial_usage_for_failure)( 

82 litellm_logging_obj=litellm_logging_obj, 

83 endpoint_type=endpoint_type, 

84 request_body=request_body, 

85 raw_bytes=raw_bytes, 

86 stream_context=stream_context, 

87 ) 

88 try: 

89 GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue( 

90 async_coroutine=litellm_logging_obj.dispatch_failure_handlers( 

91 exception, traceback.format_exc(), prefer_async_handlers=True 

92 ) 

93 ) 

94 except Exception as e: 

95 verbose_proxy_logger.error("Error scheduling stream failure logging: %s", e) 

96 

97 @staticmethod 

98 def _record_partial_usage_for_failure( 

99 litellm_logging_obj: LiteLLMLoggingObj, 

100 endpoint_type: EndpointType, 

101 request_body: dict[str, object], 

102 raw_bytes: Sequence[bytes], 

103 stream_context: PassThroughStreamContext | None, 

104 ) -> None: 

105 if endpoint_type == EndpointType.ANTHROPIC: 

106 AnthropicPassthroughLoggingHandler.record_partial_usage_for_failure( 

107 litellm_logging_obj=litellm_logging_obj, request_body=request_body, all_chunks=raw_bytes 

108 ) 

109 return 

110 if stream_context is None or not raw_bytes: 

111 return 

112 try: 

113 partial_response, kwargs = PassThroughStreamingHandler._build_passthrough_logging_result( 

114 litellm_logging_obj=litellm_logging_obj, 

115 passthrough_success_handler_obj=stream_context.passthrough_success_handler_obj, 

116 url_route=stream_context.url_route, 

117 request_body=request_body, 

118 endpoint_type=endpoint_type, 

119 start_time=stream_context.start_time, 

120 raw_bytes=raw_bytes, 

121 end_time=datetime.now(timezone.utc), 

122 model=None, 

123 ) 

124 except Exception as e: 

125 verbose_proxy_logger.warning( 

126 "Could not recover the partial usage of a failed %s pass-through stream: %s", endpoint_type.value, e 

127 ) 

128 return 

129 usage: Final = getattr(partial_response, "usage", None) 

130 if not isinstance(usage, Usage): 

131 return 

132 response_cost: Final = kwargs.get("response_cost") 

133 litellm_logging_obj.record_partial_usage_for_failure( 

134 usage=usage, 

135 response_cost=float(response_cost) if isinstance(response_cost, (int, float)) else 0.0, 

136 ) 

137 

138 @staticmethod 

139 async def chunk_processor( 

140 response: httpx.Response, 

141 request_body: dict | None, 

142 litellm_logging_obj: LiteLLMLoggingObj, 

143 endpoint_type: EndpointType, 

144 start_time: datetime, 

145 passthrough_success_handler_obj: PassThroughEndpointLogging, 

146 url_route: str, 

147 route_streaming_logging: RouteStreamingLogging | None = None, 

148 ): 

149 resolved_route_streaming_logging: Final[RouteStreamingLogging] = ( 

150 route_streaming_logging or PassThroughStreamingHandler._route_streaming_logging_to_handler 

151 ) 

152 raw_bytes: Final[list[bytes]] = [] 

153 resolved_request_body: Final[dict[str, object]] = request_body or {} 

154 

155 def _build_logging_coroutine() -> Coroutine[None, None, None]: 

156 return resolved_route_streaming_logging( 

157 litellm_logging_obj=litellm_logging_obj, 

158 passthrough_success_handler_obj=passthrough_success_handler_obj, 

159 url_route=url_route, 

160 request_body=resolved_request_body, 

161 endpoint_type=endpoint_type, 

162 start_time=start_time, 

163 raw_bytes=raw_bytes, 

164 end_time=datetime.now(), 

165 ) 

166 

167 logging_scheduled = False 

168 model_name: Final = PassThroughStreamingHandler._extract_model_for_cost_injection( 

169 request_body=request_body, 

170 url_route=url_route, 

171 endpoint_type=endpoint_type, 

172 litellm_logging_obj=litellm_logging_obj, 

173 ) 

174 

175 # Resolve once per stream rather than re-reading the global + 

176 # re-branching on every chunk. ``include_cost_in_streaming_usage`` is 

177 # set at config load and stable for the process, matching how the 

178 # proxy-level streaming fast path resolves it. 

179 cost_injection_active: Final = ( 

180 bool(getattr(litellm, "include_cost_in_streaming_usage", False)) 

181 and bool(model_name) 

182 and ( 

183 endpoint_type in (EndpointType.ANTHROPIC, EndpointType.OPENAI) 

184 or ( 

185 endpoint_type == EndpointType.VERTEX_AI 

186 and ("streamRawPredict" in url_route or "rawPredict" in url_route) 

187 ) 

188 ) 

189 ) 

190 # TinyFish SSE bills via a detached poller spawned on the first run_id frame, so disconnects can't lose the charge 

191 tinyfish_scan_active = endpoint_type == EndpointType.TINYFISH # rebind-ok: scan stops once the poller spawns 

192 tinyfish_pending = b"" # rebind-ok: SSE frame reassembly buffer across transport chunks 

193 try: 

194 if not cost_injection_active: 

195 # Hot path: just buffer for end-of-stream logging and forward. 

196 async for chunk in response.aiter_bytes(): 

197 raw_bytes.append(chunk) 

198 PassThroughStreamingHandler._stamp_first_chunk_if_needed(litellm_logging_obj) 

199 if tinyfish_scan_active: 

200 complete_frames, tinyfish_pending = split_complete_sse_frames(tinyfish_pending + chunk) 

201 run_id = run_id_from_sse_frames(complete_frames) if b"run_id" in complete_frames else None 

202 if run_id: 

203 TinyFishPassthroughLoggingHandler.start_sse_run_billing( 

204 run_id=run_id, 

205 litellm_logging_obj=litellm_logging_obj, 

206 start_time=start_time, 

207 ) 

208 tinyfish_scan_active = False 

209 yield chunk 

210 else: 

211 # ``cost_injection_active`` already requires ``model_name`` to 

212 # be truthy; pin to a typed local so mypy narrows ``Optional[str]`` 

213 # -> ``str`` for the per-chunk call site. 

214 assert model_name is not None 

215 resolved_model_name: Final[str] = model_name 

216 pending = b"" 

217 async for chunk in response.aiter_bytes(): 

218 raw_bytes.append(chunk) 

219 PassThroughStreamingHandler._stamp_first_chunk_if_needed(litellm_logging_obj) 

220 complete_frames, pending = split_complete_sse_frames(pending + chunk) 

221 if complete_frames: 

222 yield ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection( 

223 complete_frames, resolved_model_name, litellm_logging_obj 

224 ) 

225 if pending: 

226 yield pending 

227 # Stream completed cleanly. When the proxy armed deferred 

228 # dispatch (post-call guardrails active), park the logging 

229 # coroutine on logging_obj instead of enqueueing now, so 

230 # ProxyLogging._fire_deferred_stream_logging fires it after 

231 # guardrail end-of-stream blocks populate guardrail_information. 

232 # Disconnect paths skip this and fall through to the immediate 

233 # enqueue in ``finally`` to keep partial billing (LIT-2642); 

234 # upstream exceptions log a failure instead (LIT-3798). 

235 if ( 

236 getattr(litellm_logging_obj, "_on_deferred_stream_complete", None) is not None 

237 and raw_bytes 

238 and response.status_code < 400 

239 ): 

240 logging_scheduled = True 

241 bind_budget_reservation_to_callbacks(litellm_logging_obj.litellm_params) 

242 litellm_logging_obj._deferred_stream_complete_args = (_build_logging_coroutine(),) 

243 except Exception as e: 

244 verbose_proxy_logger.error("Error in chunk_processor: %s", e) 

245 if response.status_code < 400: 

246 logging_scheduled = True 

247 await PassThroughStreamingHandler.schedule_stream_failure_logging( 

248 litellm_logging_obj=litellm_logging_obj, 

249 endpoint_type=endpoint_type, 

250 request_body=resolved_request_body, 

251 raw_bytes=raw_bytes, 

252 exception=e, 

253 stream_context=PassThroughStreamContext( 

254 passthrough_success_handler_obj=passthrough_success_handler_obj, 

255 url_route=url_route, 

256 start_time=start_time, 

257 ), 

258 ) 

259 raise 

260 finally: 

261 # GeneratorExit (raised on client disconnect) is not caught by 

262 # `except Exception`; the finally block ensures partial usage 

263 # still gets logged for spend tracking. See LIT-2642. 

264 # Upstream 4xx/5xx responses are already logged as a failure by 

265 # the caller before this generator starts (see 

266 # _log_passthrough_upstream_failure); logging them again here as 

267 # a success would double-log the same request. 

268 if not logging_scheduled and raw_bytes and response.status_code < 400: 

269 logging_scheduled = True 

270 try: 

271 GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=_build_logging_coroutine()) 

272 except Exception as e: 

273 verbose_proxy_logger.error("Error scheduling chunk_processor logging: %s", e) 

274 else: 

275 bind_budget_reservation_to_callbacks(litellm_logging_obj.litellm_params) 

276 

277 @staticmethod 

278 async def _route_streaming_logging_to_handler( 

279 litellm_logging_obj: LiteLLMLoggingObj, 

280 passthrough_success_handler_obj: PassThroughEndpointLogging, 

281 url_route: str, 

282 request_body: dict, 

283 endpoint_type: EndpointType, 

284 start_time: datetime, 

285 raw_bytes: Sequence[bytes], 

286 end_time: datetime, 

287 model: str | None = None, 

288 ): 

289 """ 

290 Route the logging for the collected chunks to the appropriate handler 

291 

292 Supported endpoint types: 

293 - Anthropic 

294 - Vertex AI 

295 - OpenAI 

296 """ 

297 from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( 

298 _is_message_stop_chunk, # pyright: ignore[reportPrivateUsage] # both native stream paths share terminal-event detection 

299 _is_provider_error_chunk, # pyright: ignore[reportPrivateUsage] # provider errors must not become cache evidence 

300 ) 

301 

302 # Transport reads can split event names and JSON payloads. Recognize terminal 

303 # events only after the shared SSE framer has reassembled the collected bytes. 

304 complete_frames, incomplete_tail = split_complete_sse_frames( 

305 b"".join(raw_bytes) if endpoint_type == EndpointType.ANTHROPIC else b"" 

306 ) 

307 litellm_logging_obj.model_call_details[ # rebind-ok: stamp evidence on the per-request state read by callbacks 

308 "prompt_cache_response_complete" 

309 ] = ( 

310 endpoint_type == EndpointType.ANTHROPIC 

311 and not incomplete_tail.strip() 

312 and _is_message_stop_chunk(complete_frames) 

313 and not _is_provider_error_chunk(complete_frames) 

314 ) 

315 try: 

316 # TinyFish billing is owned by the detached poller; the $0 fallback below is only for streams with no run_id 

317 if endpoint_type == EndpointType.TINYFISH: 

318 if sse_poller_spawned(litellm_logging_obj): 

319 return 

320 late_run_id: Final = run_id_from_sse_frames(b"".join(raw_bytes)) 

321 if late_run_id: 

322 # the run_id arrived in an unterminated frame; poll to terminal instead of mispricing a RUNNING run 

323 TinyFishPassthroughLoggingHandler.start_sse_run_billing( 

324 run_id=late_run_id, 

325 litellm_logging_obj=litellm_logging_obj, 

326 start_time=start_time, 

327 ) 

328 return 

329 tinyfish_payload: Final = ( 

330 await TinyFishPassthroughLoggingHandler.handle_logging_tinyfish_collected_chunks( 

331 litellm_logging_obj=litellm_logging_obj, 

332 url_route=url_route, 

333 start_time=start_time, 

334 all_chunks=PassThroughStreamingHandler._convert_raw_bytes_to_str_lines(raw_bytes), 

335 end_time=end_time, 

336 ) 

337 ) 

338 await litellm_logging_obj.dispatch_success_handlers( 

339 result=tinyfish_payload["result"], 

340 start_time=start_time, 

341 end_time=end_time, 

342 cache_hit=litellm_logging_obj.model_call_details.get("cache_hit") is True, 

343 prefer_async_handlers=True, 

344 **tinyfish_payload["kwargs"], 

345 ) 

346 return 

347 ( 

348 standard_logging_response_object, 

349 kwargs, 

350 ) = await asyncify(PassThroughStreamingHandler._build_passthrough_logging_result)( 

351 litellm_logging_obj=litellm_logging_obj, 

352 passthrough_success_handler_obj=passthrough_success_handler_obj, 

353 url_route=url_route, 

354 request_body=request_body, 

355 endpoint_type=endpoint_type, 

356 start_time=start_time, 

357 raw_bytes=raw_bytes, 

358 end_time=end_time, 

359 model=model, 

360 ) 

361 # Always reached from an async context (anthropic_messages, 

362 # google_genai, and proxy pass-through stream tasks). prefer_async_handlers 

363 # keeps async-only loggers running even when call_type isn't pass_through 

364 # and litellm_params lacks an async flag (e.g. aanthropic_messages). 

365 await litellm_logging_obj.dispatch_success_handlers( 

366 result=standard_logging_response_object, 

367 start_time=start_time, 

368 end_time=end_time, 

369 cache_hit=litellm_logging_obj.model_call_details.get("cache_hit") is True, 

370 prefer_async_handlers=True, 

371 **kwargs, 

372 ) 

373 except Exception as e: 

374 verbose_proxy_logger.error("Error in _route_streaming_logging_to_handler: %s", e) 

375 

376 @staticmethod 

377 def _build_passthrough_logging_result( 

378 litellm_logging_obj: LiteLLMLoggingObj, 

379 passthrough_success_handler_obj: PassThroughEndpointLogging, 

380 url_route: str, 

381 request_body: dict, 

382 endpoint_type: EndpointType, 

383 start_time: datetime, 

384 raw_bytes: Sequence[bytes], 

385 end_time: datetime, 

386 model: str | None, 

387 ) -> tuple[PassThroughEndpointLoggingResultValues, dict]: 

388 """ 

389 Synchronous, CPU-bound reconstruction of the standard logging payload 

390 from collected raw SSE bytes. Extracted from 

391 _route_streaming_logging_to_handler so the per-endpoint dispatch can 

392 be unit-tested in isolation. The async callers run it in a worker 

393 thread so the token counts inside stay off the event loop. 

394 """ 

395 all_chunks: Final = PassThroughStreamingHandler._convert_raw_bytes_to_str_lines(raw_bytes) 

396 standard_logging_response_object: PassThroughEndpointLoggingResultValues | None = None 

397 kwargs: dict = {} 

398 if endpoint_type == EndpointType.ANTHROPIC: 

399 anthropic_passthrough_logging_handler_result: Final = ( 

400 AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks( 

401 litellm_logging_obj=litellm_logging_obj, 

402 passthrough_success_handler_obj=passthrough_success_handler_obj, 

403 url_route=url_route, 

404 request_body=request_body, 

405 endpoint_type=endpoint_type, 

406 start_time=start_time, 

407 all_chunks=all_chunks, 

408 end_time=end_time, 

409 ) 

410 ) 

411 standard_logging_response_object = anthropic_passthrough_logging_handler_result["result"] 

412 kwargs = anthropic_passthrough_logging_handler_result["kwargs"] 

413 elif endpoint_type == EndpointType.VERTEX_AI: 

414 vertex_passthrough_logging_handler_result: Final = ( 

415 VertexPassthroughLoggingHandler._handle_logging_vertex_collected_chunks( 

416 litellm_logging_obj=litellm_logging_obj, 

417 passthrough_success_handler_obj=passthrough_success_handler_obj, 

418 url_route=url_route, 

419 request_body=request_body, 

420 endpoint_type=endpoint_type, 

421 start_time=start_time, 

422 all_chunks=all_chunks, 

423 end_time=end_time, 

424 model=model, 

425 ) 

426 ) 

427 standard_logging_response_object = vertex_passthrough_logging_handler_result["result"] 

428 kwargs = vertex_passthrough_logging_handler_result["kwargs"] 

429 elif endpoint_type == EndpointType.GEMINI: 

430 gemini_passthrough_logging_handler_result: Final = ( 

431 GeminiPassthroughLoggingHandler._handle_logging_gemini_collected_chunks( # pyright: ignore[reportPrivateUsage] # mirrors sibling handler dispatch 

432 litellm_logging_obj=litellm_logging_obj, 

433 passthrough_success_handler_obj=passthrough_success_handler_obj, 

434 url_route=url_route, 

435 request_body=request_body, 

436 endpoint_type=endpoint_type, 

437 start_time=start_time, 

438 all_chunks=all_chunks, 

439 end_time=end_time, 

440 model=model, 

441 ) 

442 ) 

443 standard_logging_response_object = ( # rebind-ok: branch bind in shared if/elif dispatch 

444 gemini_passthrough_logging_handler_result["result"] 

445 ) 

446 kwargs = ( # rebind-ok: branch bind in shared if/elif dispatch 

447 gemini_passthrough_logging_handler_result["kwargs"] 

448 ) 

449 elif endpoint_type == EndpointType.OPENAI: 

450 openai_passthrough_logging_handler_result: Final = ( 

451 OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks( 

452 litellm_logging_obj=litellm_logging_obj, 

453 passthrough_success_handler_obj=passthrough_success_handler_obj, 

454 url_route=url_route, 

455 request_body=request_body, 

456 endpoint_type=endpoint_type, 

457 start_time=start_time, 

458 all_chunks=all_chunks, 

459 end_time=end_time, 

460 ) 

461 ) 

462 standard_logging_response_object = openai_passthrough_logging_handler_result["result"] 

463 kwargs = openai_passthrough_logging_handler_result["kwargs"] 

464 

465 if standard_logging_response_object is None: 

466 standard_logging_response_object = StandardPassThroughResponseObject( 

467 response=f"cannot parse chunks to standard response object. Chunks={all_chunks}" 

468 ) 

469 return standard_logging_response_object, kwargs 

470 

471 @staticmethod 

472 def _extract_model_for_cost_injection( 

473 request_body: dict | None, 

474 url_route: str, 

475 endpoint_type: EndpointType, 

476 litellm_logging_obj: LiteLLMLoggingObj, 

477 ) -> str | None: 

478 """ 

479 Extract model name for cost injection from various sources. 

480 """ 

481 # Try to get model from request body 

482 if request_body: 

483 model = request_body.get("model") 

484 if model: 

485 return model 

486 

487 # Try to get model from logging object 

488 if hasattr(litellm_logging_obj, "model_call_details"): 

489 model = litellm_logging_obj.model_call_details.get("model") 

490 if model: 

491 return model 

492 

493 # For Vertex AI, try to extract from URL 

494 if endpoint_type == EndpointType.VERTEX_AI: 

495 model = VertexPassthroughLoggingHandler.extract_model_from_url(url_route) 

496 if model and model != "unknown": 

497 return model 

498 

499 return None 

500 

501 @staticmethod 

502 def _convert_raw_bytes_to_str_lines(raw_bytes: Sequence[bytes]) -> list[str]: 

503 """ 

504 Converts a list of raw bytes into a list of string lines, similar to aiter_lines() 

505 

506 Args: 

507 raw_bytes: List of bytes chunks from aiter.bytes() 

508 

509 Returns: 

510 List of string lines, with each line being a complete data: {} chunk 

511 """ 

512 # errors="replace" so a stream cut mid-multibyte-sequence (client disconnect) 

513 # still decodes and logs the usage events already received, instead of raising 

514 # and dropping the whole request from SpendLogs 

515 combined_str: Final = b"".join(raw_bytes).decode("utf-8", errors="replace") 

516 

517 # Split by newlines and filter out empty lines 

518 lines: Final = [line.strip() for line in combined_str.split("\n") if line.strip()] 

519 

520 return lines