Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/common_request_processing.py: 34%

1514 statements  

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

1import asyncio 

2import contextlib 

3import json 

4import logging 

5import math 

6from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence 

7from datetime import datetime 

8from functools import lru_cache 

9from types import MappingProxyType 

10from typing import ( 

11 TYPE_CHECKING, 

12 Any, 

13 Final, 

14 Literal, 

15 NamedTuple, 

16 Protocol, 

17 TypeAlias, 

18 TypeVar, 

19 overload, 

20 runtime_checkable, 

21) 

22from urllib.parse import urlparse 

23 

24import anyio 

25import httpx 

26import orjson 

27from fastapi import HTTPException, Request, status 

28from fastapi.responses import JSONResponse, Response, StreamingResponse 

29from pydantic import BaseModel, TypeAdapter, ValidationError 

30from starlette.types import Receive, Scope, Send 

31 

32import litellm 

33from litellm._logging import redact_internal_details_from_client_message, verbose_proxy_logger 

34from litellm._uuid import uuid 

35from litellm.constants import ( 

36 DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE, 

37 DEFAULT_MAX_RECURSE_DEPTH, 

38 LITELLM_DETAILED_TIMING, 

39 LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, 

40 MAX_LITELLM_CALL_ID_LENGTH, 

41 MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG, 

42 NON_INFERENCE_CALL_TYPES, 

43 RETURN_RAW_MODEL_NAME_METADATA_KEY, 

44 STREAM_SSE_DATA_PREFIX, 

45 STREAM_SSE_KEEPALIVE_PING_BYTES, 

46 UNSAFE_PROXY_RESPONSE_HEADERS, 

47) 

48from litellm.integrations.custom_guardrail import CustomGuardrail 

49from litellm.litellm_core_utils.bug_report import ( 

50 allowlisted, 

51 bug_report_notice, 

52 should_report_bug, 

53 strip_bug_report_notice, 

54) 

55from litellm.litellm_core_utils.core_helpers import ( 

56 get_or_create_metadata_bucket, 

57 independent_snapshot, 

58 is_expected_client_error, 

59 redact_nested_match_and_regex_keys, 

60) 

61from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer 

62from litellm.litellm_core_utils.get_supported_openai_params import ( 

63 get_supported_openai_params, 

64) 

65from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params 

66from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

67from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost 

68from litellm.litellm_core_utils.llm_response_utils.get_headers import ( 

69 get_response_headers, 

70) 

71from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

72from litellm.litellm_core_utils.served_output_texts import ( 

73 record_served_output_texts, 

74 served_output_texts, 

75) 

76from litellm.litellm_core_utils.streaming_handler import ( 

77 backfill_missing_cache_usage_fields, 

78) 

79from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException, UserAPIKeyAuth 

80from litellm.proxy.auth.auth_checks import ( 

81 can_key_call_resolved_model, 

82 request_skips_budget_checks, 

83 tag_max_budget_check_for_tags, 

84) 

85from litellm.proxy.auth.auth_utils import check_response_size_is_safe, get_request_route 

86from litellm.proxy.bug_report_config import build_proxy_bug_report 

87from litellm.proxy.common_utils.callback_utils import ( 

88 get_logging_caching_headers, 

89 get_remaining_tokens_and_requests_from_request_data, 

90) 

91from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id 

92from litellm.proxy.common_utils.http_parsing_utils import ( 

93 get_client_requested_model, 

94 get_tags_from_request_body, 

95) 

96from litellm.proxy.common_utils.openai_error_payload import ( 

97 LITELLM_CALL_ID_HEADER, 

98 attribute_of, 

99 error_status_code, 

100 openai_error_param, 

101 openai_error_type, 

102) 

103from litellm.proxy.common_utils.sse_keepalive import ( 

104 SSE_COMMENT_PING_BYTES, 

105 coerce_keepalive_interval, 

106 resolve_ttft_keepalive_interval, 

107 wrap_sse_stream_with_keepalive_pings, 

108) 

109from litellm.proxy.dd_span_tagger import DDSpanTagger 

110from litellm.proxy.guardrails.auto_router_compression import arm_pre_call as _arm_auto_router_compression 

111from litellm.proxy.native_compaction import with_proxy_compaction_executor 

112from litellm.proxy.route_llm_request import route_request 

113from litellm.proxy.utils import ProxyLogging, _check_and_merge_model_level_guardrails 

114from litellm.router import Router 

115from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict 

116from litellm.router_utils.common_utils import resolve_model_group_alias 

117from litellm.types.guardrails import GuardrailEventHooks 

118from litellm.types.router import RouterRateLimitError 

119from litellm.types.router_weights import validate_router_weights 

120 

121_LateResponseT = TypeVar("_LateResponseT", bound=Response) 

122_LlmCallT = TypeVar("_LlmCallT") 

123 

124KNOWN_PROXY_ROUTES: Final = frozenset( 

125 route for member in LiteLLMRoutes for route in member.value if route.startswith("/") 

126) 

127 

128ProxyRouteType: TypeAlias = Literal[ 

129 "acompletion", 

130 "aembedding", 

131 "aresponses", 

132 "_arealtime", 

133 "_aresponses_websocket", 

134 "acreate_realtime_client_secret", 

135 "arealtime_calls", 

136 "aget_responses", 

137 "adelete_responses", 

138 "acancel_responses", 

139 "acompact_responses", 

140 "acreate_batch", 

141 "aretrieve_batch", 

142 "alist_batches", 

143 "acancel_batch", 

144 "afile_content", 

145 "afile_retrieve", 

146 "afile_delete", 

147 "atext_completion", 

148 "acreate_fine_tuning_job", 

149 "acancel_fine_tuning_job", 

150 "alist_fine_tuning_jobs", 

151 "aretrieve_fine_tuning_job", 

152 "alist_input_items", 

153 "aimage_edit", 

154 "agenerate_content", 

155 "agenerate_content_stream", 

156 "allm_passthrough_route", 

157 "avector_store_search", 

158 "avector_store_create", 

159 "avector_store_retrieve", 

160 "avector_store_list", 

161 "avector_store_update", 

162 "avector_store_delete", 

163 "avector_store_file_create", 

164 "avector_store_file_list", 

165 "avector_store_file_retrieve", 

166 "avector_store_file_content", 

167 "avector_store_file_update", 

168 "avector_store_file_delete", 

169 "aocr", 

170 "asearch", 

171 "adecisions", 

172 "avideo_generation", 

173 "avideo_list", 

174 "avideo_status", 

175 "avideo_content", 

176 "avideo_remix", 

177 "avideo_create_character", 

178 "avideo_get_character", 

179 "avideo_edit", 

180 "avideo_extension", 

181 "acreate_container", 

182 "alist_containers", 

183 "aingest", 

184 "aretrieve_container", 

185 "adelete_container", 

186 "aupload_container_file", 

187 "alist_container_files", 

188 "aretrieve_container_file", 

189 "adelete_container_file", 

190 "aretrieve_container_file_content", 

191 "acreate_skill", 

192 "alist_skills", 

193 "aget_skill", 

194 "adelete_skill", 

195 "anthropic_messages", 

196 "acreate_interaction", 

197 "aget_interaction", 

198 "adelete_interaction", 

199 "acancel_interaction", 

200 "acreate_agent", 

201 "alist_agents", 

202 "aget_agent", 

203 "adelete_agent", 

204 "alist_agent_versions", 

205 "asend_message", 

206 "call_mcp_tool", 

207 "acreate_eval", 

208 "alist_evals", 

209 "aget_eval", 

210 "aupdate_eval", 

211 "adelete_eval", 

212 "acancel_eval", 

213 "acreate_run", 

214 "alist_runs", 

215 "aget_run", 

216 "acancel_run", 

217 "adelete_run", 

218] 

219from litellm.llms.anthropic.chat.transformation import AnthropicConfig 

220 

221# Type alias for streaming chunk serializer (chunk after hooks + cost injection -> wire format) 

222StreamChunkSerializer = Callable[[object], str] 

223# Type alias for streaming error serializer (ProxyException -> wire format) 

224StreamErrorSerializer = Callable[[ProxyException], str] 

225 

226if TYPE_CHECKING: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig 

228 

229 ProxyConfig = _ProxyConfig 

230else: 

231 ProxyConfig = Any 

232from litellm.proxy.anthropic_endpoints.streaming_model_restamp import ( 

233 AnthropicStreamModelRestamper, 

234) 

235from litellm.proxy.litellm_pre_call_utils import ( 

236 add_litellm_data_to_request, 

237 refresh_proxy_server_request_body_snapshot, 

238 reject_url_valued_destination, 

239) 

240from litellm.proxy.policy_engine.response_retrieval import attach_post_call_pipelines_to_retrieval 

241from litellm.types.utils import ( 

242 ModelResponse, 

243 ModelResponseStream, 

244 StandardLoggingPayloadErrorInformation, 

245 Usage, 

246) 

247 

248# Datadog streaming spans are a no-op when ddtrace is not enabled, but the 

249# ``with tracer.trace(...)`` context manager still allocates a NullSpan and 

250# runs __enter__/__exit__ for every streamed chunk. Resolve once at import so 

251# the per-chunk hot path can skip the context manager entirely when tracing 

252# is off (the default). 

253_DD_STREAMING_TRACE_ENABLED: Final = not isinstance(tracer, NullTracer) 

254 

255 

256_CLIENT_DISCONNECTED_ERROR_INFORMATION: Final[StandardLoggingPayloadErrorInformation] = { 

257 "error_code": str(LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED), 

258 "error_message": "Client disconnected the request", 

259 "error_class": "ClientDisconnected", 

260} 

261 

262 

263def _withheld_provider_output(response: object) -> bool: 

264 return getattr(response, "has_buffered_provider_output", False) is True 

265 

266 

267def resolve_litellm_call_id(client_call_id: str | None) -> str: 

268 if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 return client_call_id 

270 return str(uuid.uuid4()) 

271 

272 

273def _should_return_raw_model_name(request_data: dict[str, object]) -> bool: 

274 return any( 

275 isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True 

276 for metadata in (request_data.get("metadata"), request_data.get("litellm_metadata")) 

277 ) 

278 

279 

280def _apply_client_disconnect_metadata(target_metadata: dict[str, object] | None) -> None: 

281 if target_metadata is None: 

282 return 

283 target_metadata["client_disconnected"] = True 

284 target_metadata["error_information"] = dict(_CLIENT_DISCONNECTED_ERROR_INFORMATION) 

285 

286 

287async def _record_streaming_client_disconnect_if_needed( 

288 request: Request | None, 

289 request_data: dict, 

290 client_disconnected: bool = False, 

291) -> bool: 

292 if not client_disconnected: 

293 if request is None: 

294 return False 

295 try: 

296 disconnected: Final = await request.is_disconnected() 

297 except Exception: # noqa: BLE001 

298 return False 

299 if not disconnected: 

300 return False 

301 

302 logging_obj: Final = request_data.get("litellm_logging_obj") 

303 if logging_obj is not None: 

304 litellm_params: Final = logging_obj.model_call_details.setdefault("litellm_params", {}) 

305 _lp_metadata = litellm_params.get("metadata") 

306 if _lp_metadata is None: 

307 _lp_metadata = {} 

308 litellm_params["metadata"] = _lp_metadata 

309 _apply_client_disconnect_metadata(_lp_metadata) 

310 

311 _mcd_metadata = logging_obj.model_call_details.get("metadata") 

312 if _mcd_metadata is None: 

313 _mcd_metadata = {} 

314 logging_obj.model_call_details["metadata"] = _mcd_metadata 

315 _apply_client_disconnect_metadata(_mcd_metadata) 

316 

317 _rd_metadata = request_data.get("metadata") 

318 if _rd_metadata is None: 

319 _rd_metadata = {} 

320 request_data["metadata"] = _rd_metadata 

321 _apply_client_disconnect_metadata(_rd_metadata) 

322 

323 _rd_litellm_params = request_data.get("litellm_params") 

324 if _rd_litellm_params is None: 

325 _rd_litellm_params = {} 

326 request_data["litellm_params"] = _rd_litellm_params 

327 _rd_lp_metadata = _rd_litellm_params.get("metadata") 

328 if _rd_lp_metadata is None: 

329 _rd_lp_metadata = {} 

330 _rd_litellm_params["metadata"] = _rd_lp_metadata 

331 _apply_client_disconnect_metadata(_rd_lp_metadata) 

332 

333 verbose_proxy_logger.debug( 

334 "Recorded streaming client disconnect with error_code=499 for litellm_call_id=%s", 

335 request_data.get("litellm_call_id"), 

336 ) 

337 return True 

338 

339 

340def _deferred_stream_logging_is_armed(request_data: dict) -> bool: 

341 logging_obj: Final = request_data.get("litellm_logging_obj") 

342 if logging_obj is None: 

343 return False 

344 return ( 

345 getattr(logging_obj, "_on_deferred_stream_complete", None) is not None 

346 and getattr(logging_obj, "_deferred_stream_complete_args", None) is not None 

347 ) 

348 

349 

350def _assembled_model_came_from_a_later_chunk(chunks: Sequence[object], assembled_model: object) -> bool: 

351 """Report whether stream_chunk_builder picked a model the first chunk did not carry. 

352 

353 Azure Model Router puts the routed model on the chunks after the first one, and the 

354 proxy deliberately leaves those chunks unrestamped so the builder can recover it. 

355 

356 A stored chunk that carries usage is a pre-restamp copy of the one the proxy saw, so 

357 an alias-restamped stream reaches the builder with the same shape: a first chunk that 

358 disagrees with the rest. Those two are only told apart by what the client asked for. 

359 """ 

360 first_chunk: Final = chunks[0] 

361 first_chunk_model: Final = ( 

362 first_chunk.get("model") if isinstance(first_chunk, dict) else getattr(first_chunk, "model", None) 

363 ) 

364 return ( 

365 isinstance(first_chunk_model, str) 

366 and isinstance(assembled_model, str) 

367 and bool(assembled_model) 

368 and assembled_model != first_chunk_model 

369 ) 

370 

371 

372def _assembled_model_is_the_name_the_client_asked_for( 

373 request_data: Mapping[str, object], 

374 assembled_model: object, 

375) -> bool: 

376 """Report whether the assembled model is the public name the proxy stamps onto chunks. 

377 

378 That stamp is what leaves an unpriced alias on the partial response, so the deployment's 

379 own model has to go back on before the row is costed. Pre-call processing rewrites 

380 `request_data["model"]` for aliasing and routing, so the client's own name wins when it 

381 is there, in the same order the proxy picks the name it stamps. 

382 """ 

383 client_requested_model: Final = request_data.get("_litellm_client_requested_model") 

384 stamped_model: Final = ( 

385 client_requested_model if isinstance(client_requested_model, str) else request_data.get("model") 

386 ) 

387 return isinstance(stamped_model, str) and assembled_model == stamped_model 

388 

389 

390async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, response: object) -> bool: 

391 """ 

392 A client disconnect throws GeneratorExit/CancelledError into the streaming 

393 generator, so neither the success nor the failure logging callback fires 

394 and the chunks already streamed (plus any sub-call cost folded into the 

395 logging object) would never reach spend tracking. Assemble the partial 

396 response from the wrapper's collected chunks and dispatch success logging 

397 for it; dispatch_success_handlers dedups against a natural end-of-stream 

398 dispatch via has_dispatched_final_stream_success. 

399 

400 Awaited directly by the shielded cleanup rather than scheduled with 

401 create_task: the client is already gone so the extra latency is harmless, 

402 and an unrooted task could be garbage-collected before it bills. 

403 

404 Returns True when a disconnect-time success event owns the request's 

405 max_parallel_requests slot release (one was dispatched here, or one had 

406 already been dispatched for this stream), so the caller can skip the 

407 explicit slot release and avoid a double release. Returns False when no 

408 success event fired (logging disabled, nothing streamed, or assembly 

409 failed) and the caller must release the slot itself. 

410 """ 

411 if litellm.disable_streaming_logging is True: 

412 return False 

413 logging_obj: Final = request_data.get("litellm_logging_obj") 

414 if not isinstance(logging_obj, LiteLLMLoggingObj): 

415 return False 

416 if logging_obj.model_call_details.get("has_dispatched_final_stream_success"): 

417 # A natural end-of-stream success event already fired and released the 

418 # slot; do not bill again, and let the caller skip the slot release. 

419 return True 

420 chunks: Final[object] = getattr(response, "chunks", None) 

421 if not isinstance(chunks, list) or not chunks: 

422 return False 

423 verbose_proxy_logger.debug( 

424 "Billing partial streamed spend for %s chunks after client disconnect, litellm_call_id=%s", 

425 len(chunks), 

426 request_data.get("litellm_call_id"), 

427 ) 

428 messages: Final[object] = getattr(response, "messages", None) 

429 try: 

430 partial_response: Final = litellm.stream_chunk_builder( 

431 chunks=chunks, 

432 messages=messages if isinstance(messages, list) else None, 

433 logging_obj=logging_obj, 

434 ) 

435 except Exception as e: # noqa: BLE001 # partial billing is best-effort; never break stream teardown 

436 verbose_proxy_logger.debug("Failed to assemble partial streamed response for disconnect billing: %s", e) 

437 return False 

438 if partial_response is None: 

439 return False 

440 wrapper_model: Final = getattr(response, "model", None) 

441 builder_recovered_the_routed_model: Final = _assembled_model_came_from_a_later_chunk( 

442 chunks, partial_response.model 

443 ) and not _assembled_model_is_the_name_the_client_asked_for(request_data, partial_response.model) 

444 if isinstance(wrapper_model, str) and wrapper_model and not builder_recovered_the_routed_model: 

445 partial_response.model = wrapper_model 

446 partial_usage: Final = getattr(partial_response, "usage", None) 

447 if isinstance(partial_usage, Usage): 

448 backfill_missing_cache_usage_fields(partial_usage) 

449 try: 

450 await logging_obj.dispatch_success_handlers( 

451 partial_response, 

452 cache_hit=False, 

453 start_time=None, 

454 end_time=None, 

455 prefer_async_handlers=True, 

456 ) 

457 except Exception as e: # noqa: BLE001 # partial billing is best-effort; never break stream teardown 

458 verbose_proxy_logger.debug("Failed to dispatch disconnect billing event: %s", e) 

459 return False 

460 return True 

461 

462 

463async def _cancel_pending_gather_tasks(tasks: Sequence["asyncio.Task[object]"]) -> None: 

464 pending_tasks: Final = [task for task in tasks if not task.done()] 

465 for task in pending_tasks: 465 ↛ 466line 465 didn't jump to line 466 because the loop on line 465 never started

466 task.cancel() 

467 for task in pending_tasks: 467 ↛ 468line 467 didn't jump to line 468 because the loop on line 467 never started

468 try: 

469 await task 

470 except (asyncio.CancelledError, Exception): # noqa: BLE001 

471 pass 

472 

473 

474@lru_cache(maxsize=512) 

475def _litellm_model_supports_stream_options(litellm_model: str) -> bool: 

476 try: 

477 supported_params: Final = get_supported_openai_params(model=litellm_model) 

478 except Exception: # noqa: BLE001 # unmapped or malformed model strings must disable injection, not fail the request 

479 return False 

480 return supported_params is not None and "stream_options" in supported_params 

481 

482 

483def _model_deployments_support_stream_options( 

484 model: object, 

485 llm_router: Router | None, 

486 team_id: str | None, 

487) -> bool: 

488 if not isinstance(model, str): 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 return False 

490 deployment_models: Final = ( 

491 llm_router.resolved_litellm_models(model, team_id=team_id) if llm_router is not None else () 

492 ) 

493 candidate_models: Final = deployment_models if deployment_models else (model,) 

494 return all(_litellm_model_supports_stream_options(m) for m in candidate_models) 

495 

496 

497def _stream_usage_tracking_updates( 

498 data: Mapping[str, object], 

499 general_settings: Mapping[str, object], 

500 route_type: str, 

501 supports_stream_options: Callable[[], bool], 

502) -> Mapping[str, object]: 

503 scrub: Final = {"_litellm_strip_stream_usage": False} if "_litellm_strip_stream_usage" in data else {} 

504 if data.get("stream", False) is not True: 

505 return scrub 

506 always_include: Final = general_settings.get("always_include_stream_usage") 

507 stream_options: Final = data.get("stream_options") 

508 if always_include is True: 508 ↛ 509line 508 didn't jump to line 509 because the condition on line 508 was never true

509 if "stream_options" not in data: 

510 return {**scrub, "stream_options": {"include_usage": True}} 

511 if isinstance(stream_options, dict) and "include_usage" not in stream_options: 

512 return {**scrub, "stream_options": {**stream_options, "include_usage": True}} 

513 return scrub 

514 if always_include is False or route_type != "acompletion": 

515 return scrub 

516 if isinstance(stream_options, dict) and stream_options.get("include_usage") is True: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true

517 return scrub 

518 if not supports_stream_options(): 518 ↛ 520line 518 didn't jump to line 520 because the condition on line 518 was always true

519 return scrub 

520 merged_stream_options: Final = {**stream_options} if isinstance(stream_options, dict) else {} 

521 return { 

522 "stream_options": {**merged_stream_options, "include_usage": True}, 

523 "_litellm_strip_stream_usage": True, 

524 } 

525 

526 

527class _UpstreamHttpResponse(Protocol): 

528 @property 

529 def status_code(self) -> int: ... 529 ↛ exitline 529 didn't return from function 'status_code' because

530 

531 @property 

532 def headers(self) -> httpx.Headers: ... 532 ↛ exitline 532 didn't return from function 'headers' because

533 

534 async def aread(self) -> bytes: ... 534 ↛ exitline 534 didn't return from function 'aread' because

535 

536 

537def _as_upstream_response(response: _UpstreamHttpResponse) -> _UpstreamHttpResponse: 

538 return response 

539 

540 

541class _ReadsHeaderValues(Protocol): 

542 def get(self, key: str, default: str = "") -> str: ... 542 ↛ exitline 542 didn't return from function 'get' because

543 

544 

545def _as_header_reader(headers: _ReadsHeaderValues) -> _ReadsHeaderValues: 

546 return headers 

547 

548 

549class _DispatchesSuccessHandlers(Protocol): 

550 async def dispatch_success_handlers( 550 ↛ exitline 550 didn't return from function 'dispatch_success_handlers' because

551 self, 

552 result: object = None, 

553 start_time: object = None, 

554 end_time: object = None, 

555 cache_hit: object = None, 

556 prefer_async_handlers: bool = False, 

557 ) -> None: ... 

558 

559 

560def _as_success_dispatcher(logging_obj: _DispatchesSuccessHandlers) -> _DispatchesSuccessHandlers: 

561 return logging_obj 

562 

563 

564def serialize_http_exception_detail( 

565 detail: object, 

566) -> tuple[str, dict | None]: 

567 """ 

568 Convert an HTTPException.detail value into (message, structured_fields) 

569 for ProxyException / SSE error frames. 

570 

571 Dict-detail HTTPExceptions raised by guardrails were previously str()-mangled 

572 into a Python repr blob, producing unparseable error responses on both the 

573 streaming and non-streaming proxy surfaces. This helper extracts a clean 

574 human-readable message while preserving the full payload as structured 

575 fields, so the dominant guardrail shapes (`{"error": "..."}` flat and 

576 `{"error": {"message": "..."}}` nested) both round-trip cleanly. 

577 """ 

578 if isinstance(detail, str): 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 return detail, None 

580 if isinstance(detail, dict): 580 ↛ 592line 580 didn't jump to line 592 because the condition on line 580 was always true

581 err: Final = detail.get("error") 

582 if isinstance(err, str): 582 ↛ 584line 582 didn't jump to line 584 because the condition on line 582 was always true

583 return err, detail 

584 if isinstance(err, dict): 

585 nested_msg: Final = err.get("message") 

586 if isinstance(nested_msg, str): 

587 return nested_msg, detail 

588 msg: Final = detail.get("message") 

589 if isinstance(msg, str): 

590 return msg, detail 

591 return json.dumps(detail), detail 

592 return str(detail), None 

593 

594 

595def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException: 

596 raw_detail: Final = attribute_of(exc, "detail", str(exc)) 

597 message, structured_fields = serialize_http_exception_detail(raw_detail) 

598 existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} 

599 merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None) 

600 error_status: Final = error_status_code(exc, status.HTTP_400_BAD_REQUEST) 

601 return ProxyException( 

602 message=message, 

603 type=openai_error_type(exc, error_status), 

604 param=openai_error_param(exc), 

605 code=error_status, 

606 provider_specific_fields=merged_fields, 

607 headers=headers, 

608 ) 

609 

610 

611def _collect_response_file_search_vector_store_ids(data: Mapping[str, object]) -> set[str]: 

612 vector_store_ids: Final[set[str]] = set() 

613 tools: Final = data.get("tools") 

614 if not isinstance(tools, list): 

615 return vector_store_ids 

616 

617 for tool in tools: 617 ↛ 618line 617 didn't jump to line 618 because the loop on line 617 never started

618 if not isinstance(tool, dict) or tool.get("type") != "file_search": 

619 continue 

620 ids = tool.get("vector_store_ids") or [] 

621 if not isinstance(ids, list): 

622 raise HTTPException( 

623 status_code=400, 

624 detail={"error": "file_search.vector_store_ids must be a list of strings"}, 

625 ) 

626 for vector_store_id in ids: 

627 if not isinstance(vector_store_id, str) or not vector_store_id: 

628 raise HTTPException( 

629 status_code=400, 

630 detail={"error": "file_search.vector_store_ids must be a list of strings"}, 

631 ) 

632 vector_store_ids.add(vector_store_id) 

633 

634 return vector_store_ids 

635 

636 

637async def _authorize_response_file_search_vector_stores( 

638 data: Mapping[str, object], 

639 user_api_key_dict: UserAPIKeyAuth, 

640) -> None: 

641 vector_store_ids: Final = _collect_response_file_search_vector_store_ids(data) 

642 if not vector_store_ids: 642 ↛ 645line 642 didn't jump to line 645 because the condition on line 642 was always true

643 return 

644 

645 from litellm.proxy.vector_store_endpoints.utils import ( 

646 assert_user_can_access_vector_store_id, 

647 ) 

648 

649 for vector_store_id in sorted(vector_store_ids): 

650 await assert_user_can_access_vector_store_id( 

651 vector_store_id=vector_store_id, 

652 user_api_key_dict=user_api_key_dict, 

653 ) 

654 

655 

656async def _resolve_per_request_model_group_alias( 

657 requested_model: object, 

658 router_settings: Mapping[str, object], 

659 user_api_key_dict: UserAPIKeyAuth, 

660 llm_router: Router, 

661) -> str | None: 

662 """ 

663 Resolve ``router_settings.model_group_alias`` coming from a key or team. 

664 

665 The Router only ever resolves aliases from its own instance attribute, which 

666 holds the global config map and is shared across requests, so a per-request 

667 map has to be applied here instead of being forwarded to the Router. 

668 

669 Auth already rewrote the body through this map for LLM API routes, so this is 

670 a fallback for callers that skipped it; the target is authorized in its own 

671 right before the rewrite, so a key that may not call it gets the usual 403. 

672 

673 Returns the target model group, or None when no alias applies. 

674 """ 

675 if not isinstance(requested_model, str): 

676 return None 

677 target: Final = resolve_model_group_alias(router_settings.get("model_group_alias"), requested_model) 

678 if target is None or target == requested_model: 

679 return None 

680 await can_key_call_resolved_model( 

681 model=target, 

682 llm_model_list=llm_router.model_list, 

683 valid_token=user_api_key_dict, 

684 llm_router=llm_router, 

685 ) 

686 return target 

687 

688 

689_REQUEST_MODEL: Final[TypeAdapter[str | list[str] | None]] = TypeAdapter(str | list[str] | None) 

690 

691 

692def _request_model(data: Mapping[str, object]) -> str | list[str] | None: 

693 try: 

694 return _REQUEST_MODEL.validate_python(data.get("model"), strict=True) 

695 except ValidationError: 

696 return None 

697 

698 

699async def _enforce_guardrail_added_tag_budgets( 

700 data: Mapping[str, object], 

701 tags_before_guardrails: frozenset[str], 

702 route: str, 

703 llm_router: Router | None, 

704 user_api_key_dict: UserAPIKeyAuth, 

705 proxy_logging_obj: ProxyLogging, 

706) -> None: 

707 added_tags: Final = tuple( 

708 tag for tag in get_tags_from_request_body(request_body=data) if tag not in tags_before_guardrails 

709 ) 

710 if not added_tags or request_skips_budget_checks(route=route, model=_request_model(data), llm_router=llm_router): 710 ↛ 712line 710 didn't jump to line 712 because the condition on line 710 was always true

711 return 

712 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache 

713 

714 try: 

715 await tag_max_budget_check_for_tags( 

716 tags=added_tags, 

717 prisma_client=prisma_client, 

718 user_api_key_cache=user_api_key_cache, 

719 proxy_logging_obj=proxy_logging_obj, 

720 valid_token=user_api_key_dict, 

721 ) 

722 except litellm.BudgetExceededError as e: 

723 raise ProxyException( 

724 message=e.message, 

725 type=ProxyErrorTypes.budget_exceeded, 

726 param=None, 

727 code=e.status_code, 

728 ) from e 

729 

730 

731async def _parse_event_data_for_error(event_line: str | bytes) -> int | None: 

732 """Parses an event line and returns an error code if present, else None.""" 

733 event_line = event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line 

734 if event_line.startswith("data: "): 

735 json_str: Final = event_line[len("data: ") :].strip() 

736 if not json_str or json_str == "[DONE]": # handle empty data or [DONE] message 

737 return None 

738 try: 

739 data: Final = orjson.loads(json_str) 

740 if isinstance(data, dict) and "error" in data and isinstance(data["error"], dict): 

741 error_code_raw: Final = data["error"].get("code") 

742 error_code: int | None = None 

743 

744 if isinstance(error_code_raw, int): 

745 error_code = error_code_raw 

746 elif isinstance(error_code_raw, str): 

747 try: 

748 error_code = int(error_code_raw) 

749 except ValueError: 

750 verbose_proxy_logger.warning( 

751 "Error code is a string but not a valid integer: %s", error_code_raw 

752 ) 

753 # Not a valid integer string, treat as if no valid code was found for this check 

754 

755 # Ensure error_code is a valid HTTP status code 

756 if error_code is not None and 100 <= error_code <= 599: 

757 return error_code 

758 elif error_code_raw is not None: # Log if original code was present but not valid 

759 verbose_proxy_logger.warning("Error has invalid or non-convertible code: %s", error_code_raw) 

760 except (orjson.JSONDecodeError, json.JSONDecodeError): 

761 # not a known error chunk 

762 pass 

763 return None 

764 

765 

766def _extract_error_from_sse_chunk(event_line: str | bytes) -> dict: 

767 """ 

768 Extract error dictionary from SSE format chunk. 

769 

770 Args: 

771 event_line: SSE format event line, e.g. "data: {"error": {...}}\n\n" 

772 

773 Returns: 

774 Error dictionary in OpenAI API format 

775 """ 

776 event_line = event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line 

777 

778 # Default error format 

779 default_error: Final = { 

780 "message": "Unknown error", 

781 "type": "internal_server_error", 

782 "param": None, 

783 "code": "500", 

784 } 

785 

786 if event_line.startswith("data: "): 

787 json_str: Final = event_line[len("data: ") :].strip() 

788 if not json_str or json_str == "[DONE]": 

789 return default_error 

790 

791 try: 

792 data: Final = orjson.loads(json_str) 

793 if isinstance(data, dict) and "error" in data: 

794 error_obj: Final = data["error"] 

795 if isinstance(error_obj, dict): 

796 return error_obj 

797 except (orjson.JSONDecodeError, json.JSONDecodeError): 

798 pass 

799 

800 return default_error 

801 

802 

803class _UpstreamClosingStreamingResponse(StreamingResponse): 

804 """StreamingResponse that always closes its body iterator and the wrapped 

805 upstream generator. 

806 

807 When the client disconnects mid-stream, Starlette abandons the body 

808 iterator without calling aclose(), leaving the upstream LLM connection 

809 open until garbage collection; the backend (e.g. vLLM) keeps generating 

810 into a dead pipe. The upstream generator is closed directly (not via the 

811 body iterator) because aclose() on a never-started generator skips its 

812 body, so a cascade through it would be a no-op if the client disconnects 

813 before the first chunk is sent. 

814 """ 

815 

816 def __init__( 

817 self, 

818 content: AsyncGenerator[str, None], 

819 *, 

820 media_type: str | None = None, 

821 headers: Mapping[str, str] | None = None, 

822 status_code: int = status.HTTP_200_OK, 

823 upstream_generator: AsyncGenerator[str, None] | None = None, 

824 ) -> None: 

825 super().__init__(content, status_code=status_code, headers=headers, media_type=media_type) 

826 self._upstream_generator = upstream_generator 

827 

828 @property 

829 def upstream_generator(self) -> AsyncGenerator[str, None] | None: 

830 """The upstream LLM stream, for a caller that has to run this response's cleanup itself.""" 

831 return self._upstream_generator 

832 

833 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 

834 try: 

835 await super().__call__(scope, receive, send) 

836 finally: 

837 with anyio.CancelScope(shield=True): 

838 for target in (self.body_iterator, self._upstream_generator): 

839 aclose = getattr(target, "aclose", None) 

840 if aclose is None: 

841 continue 

842 try: 

843 await aclose() 

844 except BaseException as e: 

845 verbose_proxy_logger.debug("error closing streaming generator: %s", e) 

846 

847 

848class _ClientDisconnectedBeforeFirstChunk(Exception): 

849 """Client went away during create_response's first-chunk buffering window. 

850 

851 The upstream LLM stream has already been closed by the time this is raised. 

852 """ 

853 

854 

855async def _wait_for_http_disconnect(request: Request) -> None: 

856 try: 

857 while True: 

858 message = await request.receive() 

859 if message.get("type") == "http.disconnect": 

860 return 

861 except asyncio.CancelledError: 

862 raise 

863 except Exception as exc: # noqa: BLE001 

864 verbose_proxy_logger.warning( 

865 "create_response: request.receive() raised %s; first-chunk disconnect monitoring disabled for this request", 

866 exc, 

867 ) 

868 # A receive() failure must not masquerade as a disconnect. 

869 await asyncio.Event().wait() 

870 

871 

872async def _buffer_first_chunk_honoring_disconnect( 

873 generator: AsyncGenerator[str, None], 

874 request: Request | None, 

875) -> str: 

876 """Fetch the first streamed chunk, cancelling the upstream LLM call if the 

877 client disconnects before it arrives. 

878 

879 create_response buffers the first chunk to detect error-only streams before 

880 handing the StreamingResponse to Starlette, which only begins listening for 

881 client disconnects once it is serving that response. A disconnect during a 

882 long time-to-first-token would otherwise leave the upstream call running 

883 until the request timeout (LIT-3568). Cancelling the fetch propagates into 

884 async_streaming_data_generator, whose finally block records the 499 and 

885 closes the upstream stream. 

886 """ 

887 if request is None: 

888 return await generator.__anext__() 

889 

890 chunk_task: Final[asyncio.Task[str]] = asyncio.ensure_future(generator.__anext__()) 

891 disconnect_task: Final[asyncio.Task[None]] = asyncio.ensure_future(_wait_for_http_disconnect(request)) 

892 try: 

893 await asyncio.wait({chunk_task, disconnect_task}, return_when=asyncio.FIRST_COMPLETED) 

894 # A completed disconnect_task has already consumed the http.disconnect 

895 # message, so Starlette's later listen_for_disconnect would never see it. 

896 # Take the cancellation path whenever a disconnect was observed, even if 

897 # the first chunk landed in the same scheduler turn. 

898 disconnect_observed: Final = disconnect_task.done() 

899 finally: 

900 disconnect_task.cancel() 

901 try: 

902 await disconnect_task 

903 except BaseException: # noqa: BLE001 

904 pass 

905 

906 if not disconnect_observed and chunk_task.done() and not chunk_task.cancelled(): 

907 return chunk_task.result() 

908 

909 chunk_task.cancel() 

910 with anyio.CancelScope(shield=True): 

911 try: 

912 await chunk_task 

913 except BaseException: # noqa: BLE001 

914 pass 

915 try: 

916 await generator.aclose() 

917 except BaseException as exc: # noqa: BLE001 

918 verbose_proxy_logger.debug("create_response: error closing generator on disconnect: %s", exc) 

919 verbose_proxy_logger.info("create_response: client disconnected before first chunk, upstream LLM request cancelled") 

920 raise _ClientDisconnectedBeforeFirstChunk() 

921 

922 

923def sse_error_payload(exc: BaseException) -> tuple[int, Mapping[str, object]]: 

924 """Build the ProxyException-shaped ``{"error": ...}`` body used in SSE error frames. 

925 

926 Matches ``ProxyException.to_dict()`` so streaming and non-streaming error frames 

927 are byte-identical. 

928 """ 

929 # Preserve status code from HTTPException (e.g. guardrail blocks) 

930 error_status: Final = error_status_code(exc, status.HTTP_500_INTERNAL_SERVER_ERROR) 

931 raw_detail: Final = attribute_of(exc, "detail", "Error processing stream start") 

932 message, structured_fields = serialize_http_exception_detail(raw_detail) 

933 

934 existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} 

935 merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None) 

936 

937 error_obj: Final = { 

938 "message": message, 

939 "type": openai_error_type(exc, error_status), 

940 "param": openai_error_param(exc), 

941 "code": str(error_status), 

942 } 

943 if not merged_fields: 

944 return error_status, error_obj 

945 return error_status, {**error_obj, "provider_specific_fields": merged_fields} 

946 

947 

948def _sse_error_frames(error_obj: Mapping[str, object]) -> tuple[str, str]: 

949 """The two frames an SSE stream ends with once it can no longer raise.""" 

950 return f"data: {json.dumps({'error': error_obj})}\n\n", "data: [DONE]\n\n" 

951 

952 

953def _sse_stream_headers(headers: Mapping[str, str]) -> Mapping[str, str]: 

954 """`headers` plus the two that stop reverse proxies from buffering SSE (issue #28384).""" 

955 return MappingProxyType({**headers, **_TTFT_KEEPALIVE_HEADERS}) 

956 

957 

958async def _resolve_stream_headers( 

959 headers: Mapping[str, str], refresh_headers: Callable[[], Awaitable[Mapping[str, str]]] | None 

960) -> Mapping[str, str]: 

961 if refresh_headers is None: 

962 return headers 

963 try: 

964 return await refresh_headers() 

965 except Exception as e: # noqa: BLE001 # a stream whose first chunk is already paid for must not fail over its headers 

966 verbose_proxy_logger.exception("Error refreshing streaming response headers: %s", e) 

967 return headers 

968 

969 

970_NO_GENERAL_SETTINGS: Final[Mapping[str, object]] = MappingProxyType({}) 

971 

972 

973async def create_response( 

974 generator: AsyncGenerator[str, None], 

975 media_type: str, 

976 headers: Mapping[str, str], 

977 default_status_code: int = status.HTTP_200_OK, 

978 request: Request | None = None, 

979 refresh_headers: Callable[[], Awaitable[Mapping[str, str]]] | None = None, 

980 general_settings: Mapping[str, object] = _NO_GENERAL_SETTINGS, 

981) -> StreamingResponse | JSONResponse: 

982 """ 

983 Create streaming response, checking if the first chunk is an error. 

984 If the first chunk is an error, return a standard JSON error response. 

985 Otherwise, return StreamingResponse and stream all content. 

986 

987 ``refresh_headers`` is consulted once the first chunk has been buffered, for 

988 callers whose headers can only be known then. ``general_settings`` decides whether 

989 the first-chunk error body also carries the ``x-litellm-call-id`` header's value. 

990 """ 

991 first_chunk_value: str | None = None 

992 final_status_code = default_status_code 

993 

994 try: 

995 # Handle coroutine that returns a generator 

996 if asyncio.iscoroutine(generator): 

997 generator = await generator 

998 

999 # Now get the first chunk from the actual generator 

1000 first_chunk_value = await _buffer_first_chunk_honoring_disconnect(generator, request) 

1001 resolved_headers: Final = await _resolve_stream_headers(headers, refresh_headers) 

1002 

1003 if first_chunk_value is not None: 

1004 try: 

1005 error_code_from_chunk: Final = await _parse_event_data_for_error(first_chunk_value) 

1006 if error_code_from_chunk is not None: 

1007 # First chunk is an error, stream hasn't really started yet 

1008 # Should return standard JSON error response instead of SSE format 

1009 final_status_code = error_code_from_chunk 

1010 verbose_proxy_logger.debug( 

1011 "Error detected in first stream chunk. Returning JSON error response with status code: %s", 

1012 final_status_code, 

1013 ) 

1014 

1015 # Parse error content 

1016 error_dict: Final = with_call_id( 

1017 JSON_OBJECT.validate_python(_extract_error_from_sse_chunk(first_chunk_value)), 

1018 error_body_call_id(general_settings, resolved_headers.get(LITELLM_CALL_ID_HEADER)), 

1019 ) 

1020 

1021 # Consume and close generator (avoid resource leak) 

1022 try: 

1023 await generator.aclose() 

1024 except Exception: 

1025 pass 

1026 

1027 # Return JSON format error response 

1028 return JSONResponse( 

1029 status_code=final_status_code, 

1030 content={"error": error_dict}, 

1031 headers=resolved_headers, 

1032 ) 

1033 except Exception as e: 

1034 verbose_proxy_logger.debug("Error parsing first chunk value: %s", e) 

1035 

1036 except _ClientDisconnectedBeforeFirstChunk: 

1037 # Client vanished during the time-to-first-token wait; the upstream 

1038 # stream is already closed. Return a 499 the (now-gone) client never reads. 

1039 return JSONResponse( 

1040 status_code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, 

1041 content={ 

1042 "error": { 

1043 "message": _CLIENT_DISCONNECT_DETAIL, 

1044 "type": "client_disconnect", 

1045 "param": None, 

1046 "code": str(LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED), 

1047 } 

1048 }, 

1049 headers=headers, 

1050 ) 

1051 except StopAsyncIteration: 

1052 # Generator was empty. Default status 

1053 async def empty_gen() -> AsyncGenerator[str, None]: 

1054 if False: 

1055 yield 

1056 

1057 return StreamingResponse( 

1058 empty_gen(), 

1059 media_type=media_type, 

1060 headers=_sse_stream_headers(await _resolve_stream_headers(headers, refresh_headers)), 

1061 status_code=default_status_code, 

1062 ) 

1063 except Exception as e: 

1064 # Unexpected error consuming first chunk. 

1065 verbose_proxy_logger.exception("Error consuming first chunk from generator: %s", e) 

1066 

1067 error_status, error_obj = sse_error_payload(e) 

1068 

1069 async def error_gen_message() -> AsyncGenerator[str, None]: 

1070 for frame in _sse_error_frames(error_obj): 

1071 yield frame 

1072 

1073 return StreamingResponse( 

1074 error_gen_message(), 

1075 media_type=media_type, 

1076 headers=_sse_stream_headers(await _resolve_stream_headers(headers, refresh_headers)), 

1077 status_code=error_status, 

1078 ) 

1079 

1080 async def combined_generator() -> AsyncGenerator[str, None]: 

1081 if not _DD_STREAMING_TRACE_ENABLED: 

1082 # Fast path: no per-chunk span object / context-manager overhead. 

1083 if first_chunk_value is not None: 

1084 yield first_chunk_value 

1085 async for chunk in generator: 

1086 yield chunk 

1087 return 

1088 if first_chunk_value is not None: 

1089 with tracer.trace(DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE): 

1090 yield first_chunk_value 

1091 async for chunk in generator: 

1092 with tracer.trace(DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE): 

1093 yield chunk 

1094 

1095 return _UpstreamClosingStreamingResponse( 

1096 combined_generator(), 

1097 media_type=media_type, 

1098 headers=_sse_stream_headers(resolved_headers), 

1099 status_code=final_status_code, 

1100 upstream_generator=generator, 

1101 ) 

1102 

1103 

1104_TTFT_KEEPALIVE_HEADERS: Final[Mapping[str, str]] = MappingProxyType( 

1105 { 

1106 "Cache-Control": "no-cache", 

1107 "X-Accel-Buffering": "no", 

1108 } 

1109) 

1110 

1111 

1112def ttft_keepalive_interval(request_data: Mapping[str, object], llm_router: Router | None = None) -> float | None: 

1113 """The operator's keepalive interval, but only for a request that asked to stream. 

1114 

1115 Resolved through the deployments the request could land on, so a deployment's 

1116 `keepalive_seconds: 0` stays the hard disable it is documented to be rather 

1117 than being switched back on by the global default. 

1118 """ 

1119 if request_data.get("stream") is not True: 

1120 return None 

1121 requested_model: Final = request_data.get("model") 

1122 deployments: Final = ( 

1123 llm_router.get_model_list(model_name=requested_model) or () 

1124 if llm_router is not None and isinstance(requested_model, str) 

1125 else () 

1126 ) 

1127 return resolve_ttft_keepalive_interval(deployments, litellm.sse_keepalive_ping_interval_seconds) 

1128 

1129 

1130async def _aclose_late_response(produced: Response) -> None: 

1131 """Run the cleanup Starlette would have run, for a response it never called. 

1132 

1133 Closing an already-closed async generator is a no-op, so this is safe to call 

1134 from both the relay's own teardown and the outer one. 

1135 """ 

1136 if not isinstance(produced, StreamingResponse): 

1137 return 

1138 targets: Final = ( 

1139 (produced.body_iterator, produced.upstream_generator) 

1140 if isinstance(produced, _UpstreamClosingStreamingResponse) 

1141 else (produced.body_iterator,) 

1142 ) 

1143 for target in targets: 

1144 aclose = getattr(target, "aclose", None) 

1145 if aclose is None: 

1146 continue 

1147 try: 

1148 await aclose() 

1149 except BaseException as exc: # noqa: BLE001 # teardown must not mask why the stream ended 

1150 verbose_proxy_logger.debug("error closing relayed streaming generator: %s", exc) 

1151 

1152 

1153async def _relay_late_response(produced: Response) -> AsyncGenerator[bytes, None]: 

1154 """Replay a Response that was built after a keepalive had already opened the wire.""" 

1155 if not isinstance(produced, StreamingResponse): 

1156 # The status line is already on the wire, so a non-streaming body, an error 

1157 # body included, can only reach the client as an SSE frame. 

1158 yield b"data: " + (bytes(produced.body) or b"{}") + b"\n\n" 

1159 yield b"data: [DONE]\n\n" 

1160 return 

1161 

1162 try: 

1163 async for chunk in produced.body_iterator: 

1164 yield chunk.encode("utf-8") if isinstance(chunk, str) else bytes(chunk) 

1165 finally: 

1166 # Starlette never called this response, so the cleanup its __call__ would 

1167 # have run has to happen here or the upstream LLM connection leaks. 

1168 with anyio.CancelScope(shield=True): 

1169 await _aclose_late_response(produced) 

1170 

1171 

1172async def _sanitized_late_failure( 

1173 exc: Exception, 

1174 on_late_failure: "Callable[[Exception], Awaitable[HTTPException | None]] | None", 

1175) -> Exception: 

1176 """Report a late failure and return whatever should reach the client. 

1177 

1178 ``post_call_failure_hook`` lets a callback replace the client-facing error, by 

1179 returning a replacement or by raising one, and both are used elsewhere in this 

1180 module. Serializing the original would leak provider detail a deployment had 

1181 configured away, so the hook's answer wins. A callback that fails some other 

1182 way is a bug in the callback, not a reason to lose the real error. 

1183 """ 

1184 if on_late_failure is None: 

1185 return exc 

1186 try: 

1187 replacement: Final = await on_late_failure(exc) 

1188 except HTTPException as raised_replacement: 

1189 return raised_replacement 

1190 except Exception as hook_failure: # noqa: BLE001 # a broken callback must not replace the real error 

1191 verbose_proxy_logger.exception("post_call_failure_hook raised while reporting a late failure: %s", hook_failure) 

1192 return exc 

1193 return replacement if replacement is not None else exc 

1194 

1195 

1196async def open_sse_before_first_byte( 

1197 produce_response: Awaitable[_LateResponseT], 

1198 ping_interval_seconds: float | str | None, 

1199 media_type: str = "text/event-stream", 

1200 on_late_failure: Callable[[Exception], Awaitable[HTTPException | None]] | None = None, 

1201) -> _LateResponseT | StreamingResponse: 

1202 """Write SSE keepalive comments while the upstream LLM call is still in flight. 

1203 

1204 The whole time-to-first-token is spent inside `produce_response`: the upstream 

1205 withholds its response headers until it emits its first token, so nothing has 

1206 entered the ASGI response phase yet and the proxy writes zero bytes. An 

1207 intermediary with an idle read timeout (AWS ALB and nginx both default to 60s) 

1208 then drops a connection that is perfectly healthy. 

1209 

1210 When `produce_response` does not finish within one interval, the response is 

1211 opened immediately and `: ping` comments, which every conformant SSE client 

1212 ignores, fill the wire until the real response is ready to be replayed onto it. 

1213 Committing the status line that early is the cost: a failure discovered after 

1214 the first ping reaches the client as an SSE error frame under a 200 rather than 

1215 as an HTTP error status, and LiteLLM's own `x-litellm-*` response headers are 

1216 not yet known. Both are why this stays off until an operator sets an interval. 

1217 """ 

1218 interval: Final = coerce_keepalive_interval(ping_interval_seconds) 

1219 if interval is None: 1219 ↛ 1222line 1219 didn't jump to line 1222 because the condition on line 1219 was always true

1220 return await produce_response 

1221 

1222 produce_task: Final = asyncio.ensure_future(produce_response) 

1223 await asyncio.wait((produce_task,), timeout=interval) 

1224 if produce_task.done(): 

1225 # Fast path: the upstream answered inside one interval, so nothing was 

1226 # written early and this is byte-identical to not being wrapped at all. 

1227 return produce_task.result() 

1228 

1229 async def keepalive_then_relay() -> AsyncGenerator[bytes, None]: 

1230 try: 

1231 while not produce_task.done(): 

1232 yield SSE_COMMENT_PING_BYTES 

1233 await asyncio.wait((produce_task,), timeout=interval) 

1234 try: 

1235 produced: Final = produce_task.result() 

1236 except Exception as exc: # noqa: BLE001 # the status line is already sent; surface it as a frame 

1237 verbose_proxy_logger.exception( 

1238 "request failed after its SSE keepalive had opened the response: %s", exc 

1239 ) 

1240 # The caller's own `except` never sees this, so its failure hook 

1241 # would never fire and the failure would go unaudited. The hook 

1242 # also gets to sanitize what reaches the client, by returning or 

1243 # raising a replacement, so its answer decides the frame. 

1244 _, error_obj = sse_error_payload(await _sanitized_late_failure(exc, on_late_failure)) 

1245 for frame in _sse_error_frames(error_obj): 

1246 yield frame.encode() 

1247 return 

1248 async for chunk in _relay_late_response(produced): 

1249 yield chunk 

1250 finally: 

1251 if not produce_task.done(): 

1252 produce_task.cancel() 

1253 with anyio.CancelScope(shield=True): 

1254 with contextlib.suppress(BaseException): 

1255 await produce_task 

1256 elif not produce_task.cancelled(): 

1257 # The upstream may have answered while nobody was draining this 

1258 # relay, e.g. the client vanished first. Nothing else holds that 

1259 # response, so its stream only gets closed here. 

1260 with anyio.CancelScope(shield=True): 

1261 with contextlib.suppress(BaseException): 

1262 await _aclose_late_response(produce_task.result()) 

1263 

1264 verbose_proxy_logger.info( 

1265 "no upstream response after %ss, opening the SSE response early and sending keepalives", interval 

1266 ) 

1267 return StreamingResponse( 

1268 keepalive_then_relay(), 

1269 media_type=media_type, 

1270 headers=_TTFT_KEEPALIVE_HEADERS, 

1271 ) 

1272 

1273 

1274def _is_azure_model_router_request(model: str, hidden_params: Mapping[str, object] | None = None) -> bool: 

1275 """ 

1276 Check if a request went down the Azure Model Router route. 

1277 

1278 ``model`` here is what the *client* sent, a model group alias with no ``model_router/`` 

1279 prefix, so matching on it alone only works when the operator happened to put "model-router" 

1280 in the alias. Where the response is in hand its stamp answers this outright, so callers 

1281 should pass ``hidden_params``. 

1282 

1283 Args: 

1284 model: The requested model name 

1285 hidden_params: ``_hidden_params`` from the response, when the caller has it 

1286 

1287 Returns: 

1288 bool: True if this is an Azure Model Router request 

1289 """ 

1290 from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo 

1291 

1292 return AzureFoundryModelInfo.is_model_router_call(model=model, hidden_params=hidden_params) 

1293 

1294 

1295def _override_openai_response_model( 

1296 *, 

1297 response_obj: object, 

1298 requested_model: str, 

1299 log_context: str, 

1300 return_raw_model_name: bool = False, 

1301) -> None: 

1302 """ 

1303 Force the OpenAI-compatible `model` field in the response to match what the client requested. 

1304 

1305 LiteLLM internally prefixes some provider/deployment model identifiers (e.g. `hosted_vllm/...`). 

1306 That internal identifier should not be returned to clients in the OpenAI `model` field. 

1307 

1308 Note: This is intentionally verbose at debug level. A model mismatch is a useful signal that an 

1309 internal model identifier is being stamped/preserved somewhere in the request/response pipeline. 

1310 We log mismatches as debug (and then restamp to the client-requested value) so these paths stay 

1311 observable for maintainers without breaking client compatibility or alarming operators. 

1312 

1313 Responses that omit an OpenAI-style `model` field are left unchanged (silent return), 

1314 including dict responses with no `model` key. 

1315 

1316 Exceptions: 

1317 1. If a fallback occurred (indicated by x-litellm-attempted-fallbacks header), 

1318 we preserve the actual model that was used (the fallback model). 

1319 2. If the request was to an Azure Model Router, we preserve the actual model 

1320 that was used (e.g., gpt-5-nano-2025-08-07) instead of the router model. 

1321 3. If this was a fastest_response batch completion, use the winning model's 

1322 model group name instead of the comma-separated list the client sent. 

1323 """ 

1324 if return_raw_model_name or not requested_model: 

1325 return 

1326 

1327 hidden_params: Final = get_hidden_params_dict(response_obj) 

1328 if isinstance(hidden_params, dict): 

1329 # Check if a fallback occurred - if so, preserve the actual model used 

1330 fallback_headers: Final = hidden_params.get("additional_headers", {}) or {} 

1331 attempted_fallbacks: Final = fallback_headers.get("x-litellm-attempted-fallbacks", None) 

1332 if attempted_fallbacks is not None and attempted_fallbacks > 0: 

1333 verbose_proxy_logger.debug( 

1334 "%s: fallback detected (attempted_fallbacks=%d), preserving actual model used instead of overriding to requested model.", 

1335 log_context, 

1336 attempted_fallbacks, 

1337 ) 

1338 return 

1339 

1340 # For fastest_response batch completions, use the winning model's group 

1341 # name rather than the comma-separated list the client sent. 

1342 if hidden_params.get("fastest_response_batch_completion"): 

1343 winning_model: Final = fallback_headers.get("x-litellm-model-group") 

1344 if winning_model: 

1345 verbose_proxy_logger.debug( 

1346 "%s: fastest_response detected, using winning model group=%r instead of requested=%r.", 

1347 log_context, 

1348 winning_model, 

1349 requested_model, 

1350 ) 

1351 requested_model = winning_model 

1352 else: 

1353 verbose_proxy_logger.debug( 

1354 "%s: fastest_response detected but no model group header found, preserving actual model from response.", 

1355 log_context, 

1356 ) 

1357 return 

1358 

1359 # Check if this is an Azure Model Router request - if so, preserve the actual model used 

1360 if _is_azure_model_router_request(requested_model, hidden_params): 

1361 verbose_proxy_logger.debug( 

1362 "%s: Azure Model Router detected - preserving actual model used from response instead of overriding to router model.", 

1363 log_context, 

1364 ) 

1365 return 

1366 

1367 if isinstance(response_obj, dict): 

1368 if "model" not in response_obj: 

1369 return 

1370 downstream_model = response_obj.get("model") 

1371 if downstream_model != requested_model: 

1372 verbose_proxy_logger.debug( 

1373 "%s: response model mismatch - requested=%r downstream=%r. Overriding response['model'] to requested model.", 

1374 log_context, 

1375 requested_model, 

1376 downstream_model, 

1377 ) 

1378 response_obj["model"] = requested_model 

1379 return 

1380 

1381 if not hasattr(response_obj, "model"): 

1382 return 

1383 

1384 downstream_model = getattr(response_obj, "model", None) 

1385 if downstream_model != requested_model: 

1386 verbose_proxy_logger.debug( 

1387 "%s: response model mismatch - requested=%r downstream=%r. Overriding response.model to requested model.", 

1388 log_context, 

1389 requested_model, 

1390 downstream_model, 

1391 ) 

1392 

1393 try: 

1394 setattr(response_obj, "model", requested_model) 

1395 except Exception as e: 

1396 verbose_proxy_logger.debug( 

1397 "%s: failed to override response.model=%r on response_type=%s. error=%s", 

1398 log_context, 

1399 requested_model, 

1400 type(response_obj), 

1401 str(e), 

1402 exc_info=True, 

1403 ) 

1404 

1405 

1406_METADATA_BUCKET_KEYS: Final = ("metadata", "litellm_metadata") 

1407_RESPONSE_REDACTED_KEYS: Final = ("keyword", "snippet", "match", "regex") 

1408 

1409 

1410def _request_metadata_buckets(request_data: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: 

1411 return tuple(bucket for key in _METADATA_BUCKET_KEYS if isinstance(bucket := request_data.get(key), Mapping)) 

1412 

1413 

1414def include_guardrail_response_requested(request_data: Mapping[str, object]) -> bool: 

1415 return any(bucket.get("include_guardrail_response") is True for bucket in _request_metadata_buckets(request_data)) 

1416 

1417 

1418def attach_guardrail_information(response: object, request_data: Mapping[str, object]) -> object: 

1419 recorded: Final[Sequence[object]] = next( 

1420 ( 

1421 entries 

1422 for bucket in _request_metadata_buckets(request_data) 

1423 if isinstance( 

1424 entries := bucket.get("standard_logging_guardrail_information"), 

1425 list, 

1426 ) 

1427 ), 

1428 (), 

1429 ) 

1430 guardrail_information: Final = [ # mutable-ok: response list contract 

1431 redact_nested_match_and_regex_keys(entry, keys=_RESPONSE_REDACTED_KEYS) 

1432 for entry in recorded 

1433 if isinstance(entry, dict) 

1434 ] 

1435 if isinstance(response, dict): 

1436 return response | MappingProxyType({"guardrail_information": guardrail_information}) 

1437 if isinstance(response, BaseModel) and response.model_config.get("extra") == "allow": 

1438 return response.model_copy(update=MappingProxyType({"guardrail_information": guardrail_information})) 

1439 return response 

1440 

1441 

1442class CostBreakdownHeaderValues(NamedTuple): 

1443 original_cost: float | None = None 

1444 discount_amount: float | None = None 

1445 margin_total_amount: float | None = None 

1446 margin_percent: float | None = None 

1447 input_cost: float | None = None 

1448 output_cost: float | None = None 

1449 cache_read_cost: float | None = None 

1450 cache_creation_cost: float | None = None 

1451 reasoning_cost: float | None = None 

1452 tool_usage_cost: float | None = None 

1453 

1454 

1455def _uncached_input_cost( 

1456 input_cost: float | None, 

1457 cache_read_cost: float | None, 

1458 cache_creation_cost: float | None, 

1459) -> float | None: 

1460 """The stored input cost nests the cache costs inside it; headers advertise the additive split instead.""" 

1461 if input_cost is None: 

1462 return None 

1463 return input_cost - (cache_read_cost or 0.0) - (cache_creation_cost or 0.0) 

1464 

1465 

1466_ZERO_COST_BREAKDOWN: Final = CostBreakdownHeaderValues( 

1467 original_cost=0.0, 

1468 discount_amount=0.0, 

1469 margin_total_amount=0.0, 

1470 margin_percent=0.0, 

1471 input_cost=0.0, 

1472 output_cost=0.0, 

1473 tool_usage_cost=0.0, 

1474) 

1475"""The component split a call priced at zero advertises, so a client reading the cost headers off a 

1476read or management route still finds the whole family rather than a partially populated one.""" 

1477 

1478 

1479def _totals_to_zero(response_cost: float | str | None) -> bool: 

1480 """Whether the total these headers carry is zero, counting a total no route ever priced as one. 

1481 

1482 A component split is only reported as zero alongside a total that agrees with it, so a read 

1483 that did price normally never advertises a real total beside an all-zero split. 

1484 """ 

1485 if response_cost is None or response_cost == "": 1485 ↛ 1486line 1485 didn't jump to line 1486 because the condition on line 1485 was never true

1486 return True 

1487 try: 

1488 return float(response_cost) == 0.0 

1489 except (TypeError, ValueError): 

1490 return False 

1491 

1492 

1493def _get_cost_breakdown_from_logging_obj( 

1494 litellm_logging_obj: LiteLLMLoggingObj | None, 

1495 response_cost: float | str | None = None, 

1496) -> CostBreakdownHeaderValues: 

1497 """Extract discount, margin, and per-component cost information from logging object's cost breakdown. 

1498 

1499 A non-inference call that priced at zero never records a breakdown, so its components are 

1500 reported as zero here. Any such call that did price normally (retrieving a background response, 

1501 and the cost poller's read of one) reports the breakdown it stored, or nothing at all when the 

1502 breakdown has not landed yet. 

1503 """ 

1504 if not litellm_logging_obj or not hasattr(litellm_logging_obj, "cost_breakdown"): 

1505 return CostBreakdownHeaderValues() 

1506 

1507 cost_breakdown: Final = litellm_logging_obj.cost_breakdown 

1508 if not cost_breakdown: 1508 ↛ 1513line 1508 didn't jump to line 1513 because the condition on line 1508 was always true

1509 if litellm_logging_obj.call_type in NON_INFERENCE_CALL_TYPES and _totals_to_zero(response_cost): 

1510 return _ZERO_COST_BREAKDOWN 

1511 return CostBreakdownHeaderValues() 

1512 

1513 return CostBreakdownHeaderValues( 

1514 original_cost=cost_breakdown.get("original_cost"), 

1515 discount_amount=cost_breakdown.get("discount_amount"), 

1516 margin_total_amount=cost_breakdown.get("margin_total_amount"), 

1517 margin_percent=cost_breakdown.get("margin_percent"), 

1518 input_cost=_uncached_input_cost( 

1519 input_cost=cost_breakdown.get("input_cost"), 

1520 cache_read_cost=cost_breakdown.get("cache_read_cost"), 

1521 cache_creation_cost=cost_breakdown.get("cache_creation_cost"), 

1522 ), 

1523 output_cost=cost_breakdown.get("output_cost"), 

1524 cache_read_cost=cost_breakdown.get("cache_read_cost"), 

1525 cache_creation_cost=cost_breakdown.get("cache_creation_cost"), 

1526 reasoning_cost=cost_breakdown.get("reasoning_cost"), 

1527 tool_usage_cost=cost_breakdown.get("tool_usage_cost"), 

1528 ) 

1529 

1530 

1531def _classifier_cost_from_request_data(request_data: Mapping[str, object] | None) -> float | None: 

1532 """Cost of the auto-router's LLM classifier call, read from the request's routing_decision. 

1533 

1534 The pre-routing hook records the decision in `litellm_metadata` on messages/batch-style 

1535 routes and in `metadata` on chat-style routes, so both buckets are consulted, in the same 

1536 precedence `get_or_create_metadata_bucket` writes them. 

1537 """ 

1538 from litellm.proxy.spend_tracking.savings import classifier_cost_from_decision 

1539 

1540 data: Final = request_data or {} 

1541 for metadata_key in ("litellm_metadata", "metadata"): 

1542 metadata = data.get(metadata_key) 

1543 if not isinstance(metadata, dict): 

1544 continue 

1545 decision = metadata.get("routing_decision") 

1546 if not isinstance(decision, dict): 1546 ↛ 1548line 1546 didn't jump to line 1548 because the condition on line 1546 was always true

1547 continue 

1548 cost = classifier_cost_from_decision(decision) 

1549 if cost is None: 

1550 continue 

1551 return cost 

1552 return None 

1553 

1554 

1555def _has_attribute_error_in_chain(exc: Exception) -> bool: 

1556 """Walk the exception chain to find an AttributeError at any depth. 

1557 

1558 Checks __cause__, __context__, and the litellm-specific original_exception 

1559 attribute iteratively. Depth is capped at DEFAULT_MAX_RECURSE_DEPTH to 

1560 avoid infinite loops from circular exception references. 

1561 """ 

1562 stack: Final[list[BaseException]] = [exc] 

1563 seen: Final[set[int]] = set() 

1564 depth = 0 

1565 while stack and depth < DEFAULT_MAX_RECURSE_DEPTH: 

1566 current = stack.pop() 

1567 exc_id = id(current) 

1568 if exc_id in seen: 1568 ↛ 1569line 1568 didn't jump to line 1569 because the condition on line 1568 was never true

1569 continue 

1570 seen.add(exc_id) 

1571 if isinstance(current, AttributeError): 

1572 return True 

1573 for attr in ("__cause__", "__context__", "original_exception"): 

1574 inner = getattr(current, attr, None) 

1575 if inner is not None and isinstance(inner, BaseException): 

1576 stack.append(inner) 

1577 depth += 1 

1578 return False 

1579 

1580 

1581_CLIENT_DISCONNECT_DETAIL: Final = "Client disconnected the request" 

1582 

1583 

1584@runtime_checkable 

1585class _CarriesLitellmCallId(Protocol): 

1586 litellm_call_id: str | None 

1587 

1588 

1589def request_litellm_call_id(data: Mapping[str, object]) -> str | None: 

1590 logging_obj: Final = data.get("litellm_logging_obj") 

1591 logged_id: Final = logging_obj.litellm_call_id if isinstance(logging_obj, _CarriesLitellmCallId) else None 

1592 call_id: Final = logged_id or data.get("litellm_call_id") 

1593 return call_id if isinstance(call_id, str) else None 

1594 

1595 

1596def log_llm_api_exception(e: Exception, litellm_call_id: str | None) -> None: 

1597 if getattr(e, "status_code", None) == 499 and getattr(e, "detail", None) == _CLIENT_DISCONNECT_DETAIL: 1597 ↛ 1598line 1597 didn't jump to line 1598 because the condition on line 1597 was never true

1598 verbose_proxy_logger.info( 

1599 "litellm.proxy.proxy_server._handle_llm_api_exception(): client disconnected, " 

1600 "upstream LLM request cancelled - litellm_call_id=%s", 

1601 litellm_call_id, 

1602 extra=MappingProxyType({"litellm_call_id": litellm_call_id}), 

1603 ) 

1604 return 

1605 log_fn: Final = ( 

1606 verbose_proxy_logger.error 

1607 if is_expected_client_error(e) and not litellm.log_client_error_tracebacks 

1608 else verbose_proxy_logger.exception 

1609 ) 

1610 log_fn( 

1611 "litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - litellm_call_id=%s - %s", 

1612 litellm_call_id, 

1613 e, 

1614 extra=MappingProxyType({"litellm_call_id": litellm_call_id}), 

1615 ) 

1616 

1617 

1618async def _cancel_llm_call_on_client_disconnect( 

1619 request: Request, 

1620 llm_api_call: "asyncio.Future[_LlmCallT]", 

1621 disconnect_event: asyncio.Event, 

1622) -> None: 

1623 try: 

1624 while True: 

1625 message = await request.receive() 

1626 if message["type"] == "http.disconnect": 

1627 disconnect_event.set() 

1628 llm_api_call.cancel() 

1629 return 

1630 except Exception as exc: 

1631 verbose_proxy_logger.warning( 

1632 "cancel_on_disconnect: request.receive() raised %s; upstream LLM call will not be cancelled on disconnect", 

1633 exc, 

1634 ) 

1635 

1636 

1637async def _await_llm_call_cancelling_on_disconnect( 

1638 request: Request, 

1639 llm_api_call: "asyncio.Future[_LlmCallT]", 

1640) -> _LlmCallT: 

1641 disconnect_event: Final = asyncio.Event() 

1642 monitor: Final = asyncio.create_task(_cancel_llm_call_on_client_disconnect(request, llm_api_call, disconnect_event)) 

1643 try: 

1644 return await llm_api_call 

1645 except asyncio.CancelledError: 

1646 if disconnect_event.is_set(): 

1647 raise HTTPException( 

1648 status_code=499, 

1649 detail=_CLIENT_DISCONNECT_DETAIL, 

1650 ) 

1651 raise 

1652 finally: 

1653 monitor.cancel() 

1654 

1655 

1656def _timing_values( 

1657 *, 

1658 hidden_params: Mapping[str, object], 

1659 logging_obj: LiteLLMLoggingObj | None, 

1660 use_logging_obj: bool, 

1661) -> Mapping[str, object]: 

1662 """Both timing values from one source, so the two headers always describe the same window. 

1663 

1664 /v1/messages returns a plain dict and the Anthropic / Responses bridge stream wrappers carry no 

1665 ``_hidden_params``, so ``update_response_metadata`` leaves their timing on the logging object. 

1666 """ 

1667 if hidden_params.get("_response_ms") is not None or not use_logging_obj or logging_obj is None: 

1668 return hidden_params 

1669 return getattr(logging_obj, "response_timing_metrics", None) or {} # mutable-ok: empty fallback 

1670 

1671 

1672class ProxyBaseLLMRequestProcessing: 

1673 def __init__(self, data: dict): 

1674 self.data = data 

1675 self._tags_before_guardrails: frozenset[str] | None = None 

1676 

1677 @property 

1678 def litellm_call_id(self) -> str | None: 

1679 return request_litellm_call_id(self.data) 

1680 

1681 @staticmethod 

1682 def _merge_passthrough_streaming_headers( 

1683 response_headers: httpx.Headers | dict | None, 

1684 custom_headers: Mapping[str, str], 

1685 ) -> dict: 

1686 """ 

1687 Merge upstream passthrough headers with proxy/custom headers. 

1688 

1689 Proxy/custom headers win on key collisions. 

1690 """ 

1691 excluded_headers: Final = { # mutable-ok: set of header names to exclude from forwarding 

1692 "transfer-encoding", 

1693 "content-encoding", 

1694 "set-cookie", 

1695 "connection", 

1696 "keep-alive", 

1697 "proxy-authenticate", 

1698 "proxy-authorization", 

1699 "te", 

1700 "trailer", 

1701 "upgrade", 

1702 } 

1703 

1704 merged_headers: Final = { # mutable-ok: dict comprehension for merged headers forwarded to httpx 

1705 key: value for key, value in dict(response_headers or {}).items() if key.lower() not in excluded_headers 

1706 } 

1707 merged_headers.update(custom_headers) 

1708 return merged_headers 

1709 

1710 @staticmethod 

1711 def get_custom_headers( 

1712 *, 

1713 user_api_key_dict: UserAPIKeyAuth, 

1714 call_id: str | None = None, 

1715 model_id: str | None = None, 

1716 cache_key: str | None = None, 

1717 api_base: str | None = None, 

1718 version: str | None = None, 

1719 model_region: str | None = None, 

1720 response_cost: float | str | None = None, 

1721 hidden_params: Mapping[str, object] | None = None, 

1722 fastest_response_batch_completion: bool | None = None, 

1723 request_data: dict | None = {}, 

1724 timeout: float | httpx.Timeout | None = None, 

1725 litellm_logging_obj: LiteLLMLoggingObj | None = None, 

1726 read_timing_from_logging_obj: bool = True, 

1727 **kwargs, 

1728 ) -> dict: 

1729 exclude_values: Final = {"", None, "None"} 

1730 hidden_params = hidden_params or {} 

1731 resolved_call_id: Final = ( 

1732 call_id or hidden_params.get("litellm_call_id") or (request_data or {}).get("litellm_call_id") 

1733 ) 

1734 timing_values: Final = _timing_values( 

1735 hidden_params=hidden_params, 

1736 logging_obj=litellm_logging_obj, 

1737 use_logging_obj=read_timing_from_logging_obj, 

1738 ) 

1739 

1740 cost_breakdown: Final = _get_cost_breakdown_from_logging_obj( 

1741 litellm_logging_obj=litellm_logging_obj, response_cost=response_cost 

1742 ) 

1743 

1744 # Calculate updated spend for header (include current response_cost) 

1745 current_spend: Final = user_api_key_dict.spend or 0.0 

1746 updated_spend = current_spend 

1747 if response_cost is not None: 

1748 try: 

1749 # Convert response_cost to float if it's a string 

1750 cost_value: Final = float(response_cost) if isinstance(response_cost, str) else response_cost 

1751 if cost_value > 0: 1751 ↛ 1752line 1751 didn't jump to line 1752 because the condition on line 1751 was never true

1752 updated_spend = current_spend + cost_value 

1753 except (ValueError, TypeError): 

1754 # If conversion fails, use original spend 

1755 pass 

1756 

1757 model_name: Final = ProxyBaseLLMRequestProcessing._get_deployment_model_name(litellm_logging_obj) 

1758 classifier_cost: Final = _classifier_cost_from_request_data(request_data) 

1759 

1760 headers: Final = { 

1761 "x-litellm-call-id": resolved_call_id, 

1762 "x-litellm-model-id": model_id, 

1763 "x-litellm-model-name": model_name, 

1764 "x-litellm-cache-key": cache_key, 

1765 "x-litellm-model-api-base": ( 

1766 api_base.split("?")[0] if api_base else None 

1767 ), # don't include query params, risk of leaking sensitive info 

1768 "x-litellm-version": version, 

1769 "x-litellm-model-region": model_region, 

1770 "x-litellm-response-cost": str(response_cost), 

1771 "x-litellm-response-cost-original": ( 

1772 str(cost_breakdown.original_cost) if cost_breakdown.original_cost is not None else None 

1773 ), 

1774 "x-litellm-response-cost-discount-amount": ( 

1775 str(cost_breakdown.discount_amount) if cost_breakdown.discount_amount is not None else None 

1776 ), 

1777 "x-litellm-response-cost-margin-amount": ( 

1778 str(cost_breakdown.margin_total_amount) if cost_breakdown.margin_total_amount is not None else None 

1779 ), 

1780 "x-litellm-response-cost-margin-percent": ( 

1781 str(cost_breakdown.margin_percent) if cost_breakdown.margin_percent is not None else None 

1782 ), 

1783 "x-litellm-response-cost-input": ( 

1784 str(cost_breakdown.input_cost) if cost_breakdown.input_cost is not None else None 

1785 ), 

1786 "x-litellm-response-cost-output": ( 

1787 str(cost_breakdown.output_cost) if cost_breakdown.output_cost is not None else None 

1788 ), 

1789 "x-litellm-response-cost-cache-read": ( 

1790 str(cost_breakdown.cache_read_cost) if cost_breakdown.cache_read_cost is not None else None 

1791 ), 

1792 "x-litellm-response-cost-cache-creation": ( 

1793 str(cost_breakdown.cache_creation_cost) if cost_breakdown.cache_creation_cost is not None else None 

1794 ), 

1795 "x-litellm-response-cost-reasoning": ( 

1796 str(cost_breakdown.reasoning_cost) if cost_breakdown.reasoning_cost is not None else None 

1797 ), 

1798 "x-litellm-response-cost-tool-usage": ( 

1799 str(cost_breakdown.tool_usage_cost) if cost_breakdown.tool_usage_cost is not None else None 

1800 ), 

1801 "x-litellm-classifier-cost": (str(classifier_cost) if classifier_cost is not None else None), 

1802 "x-litellm-key-tpm-limit": str(user_api_key_dict.tpm_limit), 

1803 "x-litellm-key-rpm-limit": str(user_api_key_dict.rpm_limit), 

1804 "x-litellm-key-max-budget": str(user_api_key_dict.max_budget), 

1805 "x-litellm-key-spend": str(updated_spend), 

1806 "x-litellm-response-duration-ms": str(timing_values.get("_response_ms")), 

1807 "x-litellm-overhead-duration-ms": str(timing_values.get("litellm_overhead_time_ms")), 

1808 "x-litellm-callback-duration-ms": str(hidden_params.get("callback_duration_ms", None)), 

1809 **( 

1810 { 

1811 "x-litellm-timing-pre-processing-ms": str(hidden_params.get("timing_pre_processing_ms", None)), 

1812 "x-litellm-timing-llm-api-ms": str(hidden_params.get("timing_llm_api_ms", None)), 

1813 "x-litellm-timing-post-processing-ms": str(hidden_params.get("timing_post_processing_ms", None)), 

1814 "x-litellm-timing-message-copy-ms": str(hidden_params.get("timing_message_copy_ms", None)), 

1815 } 

1816 if LITELLM_DETAILED_TIMING 

1817 else {} 

1818 ), 

1819 "x-litellm-fastest_response_batch_completion": ( 

1820 str(fastest_response_batch_completion) if fastest_response_batch_completion is not None else None 

1821 ), 

1822 "x-litellm-timeout": str(timeout) if timeout is not None else None, 

1823 **{k: str(v) for k, v in kwargs.items()}, 

1824 } 

1825 if request_data: 

1826 remaining_tokens_header: Final = get_remaining_tokens_and_requests_from_request_data(request_data) 

1827 headers.update(remaining_tokens_header) 

1828 

1829 logging_caching_headers: Final = get_logging_caching_headers(request_data) 

1830 if logging_caching_headers: 

1831 headers.update(logging_caching_headers) 

1832 

1833 try: 

1834 return {key: str(value) for key, value in headers.items() if value not in exclude_values} 

1835 except Exception as e: 

1836 verbose_proxy_logger.error("Error setting custom headers: %s", e) 

1837 return {} 

1838 

1839 @staticmethod 

1840 async def build_litellm_proxy_success_headers_from_llm_response( 

1841 *, 

1842 response: object, 

1843 request_data: dict, 

1844 request: Request, 

1845 user_api_key_dict: UserAPIKeyAuth, 

1846 logging_obj: LiteLLMLoggingObj, 

1847 version: str | None, 

1848 proxy_logging_obj: ProxyLogging, 

1849 ) -> dict[str, str]: 

1850 """ 

1851 Build LiteLLM proxy response headers for routes that call the LLM directly 

1852 (e.g. Google native :generateContent) instead of base_process_llm_request. 

1853 """ 

1854 if isinstance(response, dict): 

1855 hidden_params = get_hidden_params_dict(response) 

1856 else: 

1857 hidden_params = getattr(response, "_hidden_params", None) or {} 

1858 if not isinstance(hidden_params, dict): 

1859 hidden_params = {} 

1860 

1861 model_id: Final = ProxyBaseLLMRequestProcessing._get_model_id_from_response(hidden_params, request_data) 

1862 

1863 cache_key: Final = hidden_params.get("cache_key", None) or "" 

1864 api_base: Final = hidden_params.get("api_base", None) or "" 

1865 response_cost: Final = hidden_params.get("response_cost", None) or "" 

1866 fastest_response_batch_completion: Final = hidden_params.get("fastest_response_batch_completion", None) 

1867 additional_headers: Final = hidden_params.get("additional_headers", {}) or {} 

1868 

1869 custom_headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers( 

1870 user_api_key_dict=user_api_key_dict, 

1871 call_id=logging_obj.litellm_call_id, 

1872 model_id=model_id, 

1873 cache_key=cache_key, 

1874 api_base=api_base, 

1875 version=version, 

1876 response_cost=response_cost, 

1877 model_region=getattr(user_api_key_dict, "allowed_model_region", ""), 

1878 fastest_response_batch_completion=fastest_response_batch_completion, 

1879 request_data=request_data, 

1880 hidden_params=hidden_params, 

1881 litellm_logging_obj=logging_obj, 

1882 **additional_headers, 

1883 ) 

1884 

1885 callback_headers: Final = await proxy_logging_obj.post_call_response_headers_hook( 

1886 data=request_data, 

1887 user_api_key_dict=user_api_key_dict, 

1888 response=response, 

1889 request_headers=dict(request.headers), 

1890 ) 

1891 if callback_headers: 

1892 custom_headers.update(callback_headers) 

1893 

1894 return custom_headers 

1895 

1896 async def common_processing_pre_call_logic( 

1897 self, 

1898 request: Request, 

1899 general_settings: dict, 

1900 user_api_key_dict: UserAPIKeyAuth, 

1901 proxy_logging_obj: ProxyLogging, 

1902 proxy_config: ProxyConfig, 

1903 route_type: Literal[ 

1904 "acompletion", 

1905 "aembedding", 

1906 "aresponses", 

1907 "_arealtime", 

1908 "_aresponses_websocket", 

1909 "acreate_realtime_client_secret", 

1910 "arealtime_calls", 

1911 "aget_responses", 

1912 "adelete_responses", 

1913 "acancel_responses", 

1914 "acompact_responses", 

1915 "acreate_batch", 

1916 "aretrieve_batch", 

1917 "alist_batches", 

1918 "acancel_batch", 

1919 "afile_content", 

1920 "afile_retrieve", 

1921 "afile_delete", 

1922 "atext_completion", 

1923 "acreate_fine_tuning_job", 

1924 "acancel_fine_tuning_job", 

1925 "alist_fine_tuning_jobs", 

1926 "aretrieve_fine_tuning_job", 

1927 "alist_input_items", 

1928 "aimage_edit", 

1929 "agenerate_content", 

1930 "agenerate_content_stream", 

1931 "allm_passthrough_route", 

1932 "avector_store_search", 

1933 "avector_store_create", 

1934 "avector_store_retrieve", 

1935 "avector_store_list", 

1936 "avector_store_update", 

1937 "avector_store_delete", 

1938 "avector_store_file_create", 

1939 "avector_store_file_list", 

1940 "avector_store_file_retrieve", 

1941 "avector_store_file_content", 

1942 "avector_store_file_update", 

1943 "avector_store_file_delete", 

1944 "aocr", 

1945 "asearch", 

1946 "adecisions", 

1947 "avideo_generation", 

1948 "avideo_list", 

1949 "avideo_status", 

1950 "avideo_content", 

1951 "avideo_remix", 

1952 "avideo_create_character", 

1953 "avideo_get_character", 

1954 "avideo_edit", 

1955 "avideo_extension", 

1956 "acreate_container", 

1957 "alist_containers", 

1958 "aingest", 

1959 "aretrieve_container", 

1960 "adelete_container", 

1961 "aupload_container_file", 

1962 "alist_container_files", 

1963 "aretrieve_container_file", 

1964 "adelete_container_file", 

1965 "aretrieve_container_file_content", 

1966 "acreate_skill", 

1967 "alist_skills", 

1968 "aget_skill", 

1969 "adelete_skill", 

1970 "anthropic_messages", 

1971 "acreate_interaction", 

1972 "aget_interaction", 

1973 "adelete_interaction", 

1974 "acancel_interaction", 

1975 "acreate_agent", 

1976 "alist_agents", 

1977 "aget_agent", 

1978 "adelete_agent", 

1979 "alist_agent_versions", 

1980 "asend_message", 

1981 "call_mcp_tool", 

1982 "acreate_eval", 

1983 "alist_evals", 

1984 "aget_eval", 

1985 "aupdate_eval", 

1986 "adelete_eval", 

1987 "acancel_eval", 

1988 "acreate_run", 

1989 "alist_runs", 

1990 "aget_run", 

1991 "acancel_run", 

1992 "adelete_run", 

1993 "apply_guardrail", 

1994 ], 

1995 version: str | None = None, 

1996 user_model: str | None = None, 

1997 user_temperature: float | None = None, 

1998 user_request_timeout: float | None = None, 

1999 user_max_tokens: int | None = None, 

2000 user_api_base: str | None = None, 

2001 model: str | None = None, 

2002 llm_router: Router | None = None, 

2003 rate_limited_model: str | None = None, 

2004 skip_guardrails: bool = False, 

2005 ) -> tuple[dict, LiteLLMLoggingObj]: 

2006 start_time: Final = datetime.now() # start before calling guardrail hooks 

2007 

2008 requested_model: Final = self.data.get("model") 

2009 if requested_model is not None and not isinstance(requested_model, str): 

2010 raise ProxyException( 

2011 message="'model' must be a string.", 

2012 type=ProxyErrorTypes.bad_request_error, 

2013 param="model", 

2014 code=status.HTTP_400_BAD_REQUEST, 

2015 ) 

2016 self.data = await add_litellm_data_to_request( 

2017 data=self.data, 

2018 request=request, 

2019 general_settings=general_settings, 

2020 user_api_key_dict=user_api_key_dict, 

2021 version=version, 

2022 proxy_config=proxy_config, 

2023 ) 

2024 if not general_settings.get("expose_fallback_errors_to_caller"): 2024 ↛ 2026line 2024 didn't jump to line 2026 because the condition on line 2024 was always true

2025 self.data.pop("include_fallback_errors", None) 

2026 if route_type in {"aresponses", "_aresponses_websocket"}: 

2027 await _authorize_response_file_search_vector_stores( 

2028 data=self.data, 

2029 user_api_key_dict=user_api_key_dict, 

2030 ) 

2031 # Calculate request queue time after add_litellm_data_to_request 

2032 # which sets arrival_time in proxy_server_request. Ends at start_time 

2033 # (not a freshly captured time.time() here) so this window is exactly 

2034 # [arrival_time, start_time], with zero overlap with the 

2035 # litellm_request_total_latency_metric window of [start_time, end_time] -- 

2036 # otherwise the few lines of add_litellm_data_to_request's own work would 

2037 # be double-counted across both metrics. 

2038 proxy_server_request: Final = self.data.get("proxy_server_request", {}) 

2039 arrival_time: Final = proxy_server_request.get("arrival_time") 

2040 queue_time_seconds = None 

2041 if arrival_time is not None: 2041 ↛ 2045line 2041 didn't jump to line 2045 because the condition on line 2041 was always true

2042 queue_time_seconds = start_time.timestamp() - arrival_time 

2043 

2044 # Store queue time in metadata after add_litellm_data_to_request to ensure it's preserved 

2045 if queue_time_seconds is not None: 2045 ↛ 2055line 2045 didn't jump to line 2055 because the condition on line 2045 was always true

2046 from litellm.proxy.litellm_pre_call_utils import _get_metadata_variable_name 

2047 

2048 _metadata_variable_name: Final = _get_metadata_variable_name(request) 

2049 if _metadata_variable_name not in self.data: 2049 ↛ 2050line 2049 didn't jump to line 2050 because the condition on line 2049 was never true

2050 self.data[_metadata_variable_name] = {} 

2051 if not isinstance(self.data[_metadata_variable_name], dict): 2051 ↛ 2052line 2051 didn't jump to line 2052 because the condition on line 2051 was never true

2052 self.data[_metadata_variable_name] = {} 

2053 self.data[_metadata_variable_name]["queue_time_seconds"] = queue_time_seconds 

2054 

2055 if isinstance(model, str): 

2056 reject_url_valued_destination("model", model) 

2057 

2058 self.data["model"] = ( 

2059 general_settings.get("completion_model", None) # server default 

2060 or user_model # model name passed via cli args 

2061 or model # for azure deployments 

2062 or self.data.get("model", None) # default passed in http request 

2063 ) 

2064 

2065 # override with user settings, these are params passed via cli 

2066 if user_temperature: 2066 ↛ 2067line 2066 didn't jump to line 2067 because the condition on line 2066 was never true

2067 self.data["temperature"] = user_temperature 

2068 if user_request_timeout: 2068 ↛ 2069line 2068 didn't jump to line 2069 because the condition on line 2068 was never true

2069 self.data["request_timeout"] = user_request_timeout 

2070 if user_max_tokens: 2070 ↛ 2071line 2070 didn't jump to line 2071 because the condition on line 2070 was never true

2071 self.data["max_tokens"] = user_max_tokens 

2072 if user_api_base: 2072 ↛ 2073line 2072 didn't jump to line 2073 because the condition on line 2072 was never true

2073 self.data["api_base"] = user_api_base 

2074 

2075 ### MODEL ALIAS MAPPING ### 

2076 # check if model name in model alias map 

2077 # get the actual model name 

2078 if isinstance(self.data["model"], str) and self.data["model"] in litellm.model_alias_map: 2078 ↛ 2079line 2078 didn't jump to line 2079 because the condition on line 2078 was never true

2079 self.data["model"] = litellm.model_alias_map[self.data["model"]] 

2080 

2081 # Check key-specific aliases 

2082 if ( 2082 ↛ 2088line 2082 didn't jump to line 2088 because the condition on line 2082 was never true

2083 isinstance(self.data["model"], str) 

2084 and user_api_key_dict.aliases 

2085 and isinstance(user_api_key_dict.aliases, dict) 

2086 and self.data["model"] in user_api_key_dict.aliases 

2087 ): 

2088 self.data["model"] = user_api_key_dict.aliases[self.data["model"]] 

2089 

2090 # Apply hierarchical router_settings (Key > Team) 

2091 # Global router_settings are already on the Router object itself. 

2092 # This sits with the other alias rewrites, and ahead of the guardrail 

2093 # merge and the pre-call hooks, so everything that keys off the model 

2094 # group -- model-level guardrails, per-model budgets and rate limits, 

2095 # the logging object -- sees the group that will actually serve. 

2096 if llm_router is not None and proxy_config is not None: 

2097 from litellm.proxy.proxy_server import prisma_client 

2098 

2099 router_settings: Final = await proxy_config._get_hierarchical_router_settings( 

2100 user_api_key_dict=user_api_key_dict, 

2101 prisma_client=prisma_client, 

2102 proxy_logging_obj=proxy_logging_obj, 

2103 ) 

2104 

2105 # If router_settings found (from key or team), apply them 

2106 # Pass settings as per-request overrides instead of creating a new Router 

2107 # This avoids expensive Router instantiation on each request 

2108 if router_settings is not None: 2108 ↛ 2109line 2108 didn't jump to line 2109 because the condition on line 2108 was never true

2109 self.data["router_settings_override"] = router_settings 

2110 try: 

2111 self.data["_router_weights"] = validate_router_weights(router_settings.get("weights")) 

2112 except ValidationError: 

2113 self.data["_router_weights"] = None 

2114 verbose_proxy_logger.warning( 

2115 "Ignoring invalid saved router weights; update team/key router_settings" 

2116 ) 

2117 alias_target: Final = await _resolve_per_request_model_group_alias( 

2118 requested_model=self.data.get("model"), 

2119 router_settings=router_settings, 

2120 user_api_key_dict=user_api_key_dict, 

2121 llm_router=llm_router, 

2122 ) 

2123 if alias_target is not None: 

2124 self.data["model"] = alias_target 

2125 

2126 self.data["litellm_call_id"] = resolve_litellm_call_id(request.headers.get("x-litellm-call-id")) 

2127 DDSpanTagger.tag_call_id(self.data.get("litellm_call_id")) 

2128 DDSpanTagger.tag_request( 

2129 user_api_key_dict=user_api_key_dict, 

2130 requested_model=self.data.get("model"), 

2131 ) 

2132 

2133 ### AUTO STREAM USAGE TRACKING ### 

2134 self.data.update( 

2135 _stream_usage_tracking_updates( 

2136 data=self.data, 

2137 general_settings=general_settings, 

2138 route_type=route_type, 

2139 supports_stream_options=lambda: _model_deployments_support_stream_options( 

2140 model=self.data.get("model"), 

2141 llm_router=llm_router, 

2142 team_id=user_api_key_dict.team_id, 

2143 ), 

2144 ) 

2145 ) 

2146 ### CALL HOOKS ### - modify/reject incoming data before calling the model 

2147 

2148 ## LOGGING OBJECT ## - initialize logging object for logging success/failure events for call 

2149 ## IMPORTANT Note: - initialize this before running pre-call checks. Ensures we log rejected requests to langfuse. 

2150 logging_obj, self.data = litellm.utils.function_setup( 

2151 original_function=route_type, 

2152 rules_obj=litellm.utils.Rules(), 

2153 start_time=start_time, 

2154 **self.data, 

2155 ) 

2156 

2157 self.data["litellm_logging_obj"] = logging_obj 

2158 

2159 # Merge model-level guardrails before pre_call_hook so DB/UI-configured 

2160 # guardrails actually execute on pre_call. Without this, guardrails set 

2161 # via litellm_params.guardrails are only honored on post_call paths 

2162 # (#29652, partial fix in #23774 covered non-streaming post_call only). 

2163 # trust_client_model_info=False on pre_call: route_request hasn't run 

2164 # and add_litellm_data_to_request preserves client-supplied 

2165 # model_info when allow_client_pricing_override is set, so a caller 

2166 # could otherwise spoof an unguarded model_info.id while requesting 

2167 # a guarded alias and bypass guardrails (veria-ai HIGH on #29654). 

2168 merged_for_requested: Final = ( 

2169 self.data 

2170 if rate_limited_model is None 

2171 else _check_and_merge_model_level_guardrails( 

2172 data=self.data, llm_router=llm_router, trust_client_model_info=False, model_alias=rate_limited_model 

2173 ) 

2174 ) 

2175 self.data = _check_and_merge_model_level_guardrails( 

2176 data=merged_for_requested, 

2177 llm_router=llm_router, 

2178 trust_client_model_info=False, 

2179 ) 

2180 

2181 # An auto router with its own compression policy is authoritative for this 

2182 # request: suppress every other compression guardrail and arm whichever one 

2183 # the policy names for the model call, before those guardrails get a chance 

2184 # to run below. 

2185 await _arm_auto_router_compression(data=self.data, llm_router=llm_router) 

2186 

2187 if self._tags_before_guardrails is None: 2187 ↛ 2189line 2187 didn't jump to line 2189 because the condition on line 2187 was always true

2188 self._tags_before_guardrails = frozenset(get_tags_from_request_body(request_body=self.data)) 

2189 self.data = await proxy_logging_obj.pre_call_hook( 

2190 user_api_key_dict=user_api_key_dict, 

2191 data=self.data, 

2192 call_type=route_type, 

2193 skip_guardrails=skip_guardrails, 

2194 ) 

2195 await _enforce_guardrail_added_tag_budgets( 

2196 data=self.data, 

2197 tags_before_guardrails=self._tags_before_guardrails, 

2198 route=get_request_route(request=request), 

2199 llm_router=llm_router, 

2200 user_api_key_dict=user_api_key_dict, 

2201 proxy_logging_obj=proxy_logging_obj, 

2202 ) 

2203 if route_type == "aget_responses": 

2204 attach_post_call_pipelines_to_retrieval( 

2205 data=self.data, 

2206 user_api_key_dict=user_api_key_dict, 

2207 llm_router=llm_router, 

2208 ) 

2209 

2210 # Refresh AFTER pre_call_hook: guardrails (e.g. Presidio PII masking) may 

2211 # have mutated `self.data` in place, and the audit-trail snapshot taken in 

2212 # add_litellm_data_to_request predates that mutation. 

2213 refresh_proxy_server_request_body_snapshot(self.data, guardrails_applied=True) 

2214 verbose_proxy_logger.debug("receiving data: %s", self.data) 

2215 

2216 if "messages" in self.data and self.data["messages"]: 

2217 logging_obj.update_messages(self.data["messages"]) 

2218 

2219 return self.data, logging_obj 

2220 

2221 async def _pre_call_with_fallbacks( 

2222 self, 

2223 request: Request, 

2224 general_settings: dict, 

2225 proxy_logging_obj: ProxyLogging, 

2226 user_api_key_dict: UserAPIKeyAuth, 

2227 version: str | None, 

2228 proxy_config: ProxyConfig, 

2229 user_model: str | None, 

2230 user_temperature: float | None, 

2231 user_request_timeout: float | None, 

2232 user_max_tokens: int | None, 

2233 user_api_base: str | None, 

2234 model: str | None, 

2235 route_type: str, 

2236 llm_router: Router | None, 

2237 ) -> tuple[dict, LiteLLMLoggingObj]: 

2238 from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError 

2239 

2240 configured_fallbacks: Final = ( 

2241 self._configured_fallbacks(llm_router=llm_router, user_api_key_dict=user_api_key_dict) 

2242 if llm_router is not None 

2243 else None 

2244 ) 

2245 pristine: Final = independent_snapshot(self.data) if configured_fallbacks else None 

2246 

2247 try: 

2248 return await self.common_processing_pre_call_logic( 

2249 request=request, 

2250 general_settings=general_settings, 

2251 proxy_logging_obj=proxy_logging_obj, 

2252 user_api_key_dict=user_api_key_dict, 

2253 version=version, 

2254 proxy_config=proxy_config, 

2255 user_model=user_model, 

2256 user_temperature=user_temperature, 

2257 user_request_timeout=user_request_timeout, 

2258 user_max_tokens=user_max_tokens, 

2259 user_api_base=user_api_base, 

2260 model=model, 

2261 route_type=route_type, 

2262 llm_router=llm_router, 

2263 ) 

2264 except ProxyRateLimitError as original_exc: 

2265 rate_limited_data: Final = self.data 

2266 original_model: Final = rate_limited_data.get("model") 

2267 if ( 

2268 pristine is None 

2269 or not configured_fallbacks 

2270 or rate_limited_data.get("disable_fallbacks") 

2271 or not isinstance(original_model, str) 

2272 ): 

2273 raise 

2274 

2275 fallback_models: Final = self._resolve_fallback_models( 

2276 model=original_model, 

2277 fallbacks=configured_fallbacks, 

2278 ) 

2279 if not fallback_models: 

2280 raise 

2281 

2282 verbose_proxy_logger.info( 

2283 "Local rate limit hit for model=%s, attempting fallbacks: %s", 

2284 original_model, 

2285 fallback_models, 

2286 ) 

2287 try: 

2288 for fallback_model in fallback_models: 

2289 if fallback_model == original_model: 

2290 continue 

2291 self.data = independent_snapshot(pristine) 

2292 self.data["model"] = fallback_model 

2293 try: 

2294 return await self.common_processing_pre_call_logic( 

2295 request=request, 

2296 general_settings=general_settings, 

2297 proxy_logging_obj=proxy_logging_obj, 

2298 user_api_key_dict=user_api_key_dict, 

2299 version=version, 

2300 proxy_config=proxy_config, 

2301 user_model=user_model, 

2302 user_temperature=user_temperature, 

2303 user_request_timeout=user_request_timeout, 

2304 user_max_tokens=user_max_tokens, 

2305 user_api_base=user_api_base, 

2306 model=fallback_model, 

2307 route_type=route_type, 

2308 llm_router=llm_router, 

2309 rate_limited_model=original_model, 

2310 ) 

2311 except ProxyRateLimitError: 

2312 continue 

2313 except BaseException: 

2314 self.data = rate_limited_data 

2315 raise 

2316 

2317 self.data = rate_limited_data 

2318 raise original_exc 

2319 

2320 @staticmethod 

2321 def _configured_fallbacks(llm_router: Router, user_api_key_dict: UserAPIKeyAuth) -> list | None: 

2322 key_router_settings: Final = user_api_key_dict.router_settings 

2323 key_fallbacks: Final = key_router_settings.get("fallbacks") if isinstance(key_router_settings, dict) else None 

2324 fallbacks: Final = key_fallbacks if key_fallbacks is not None else llm_router.fallbacks 

2325 return fallbacks if isinstance(fallbacks, list) and fallbacks else None 

2326 

2327 @staticmethod 

2328 def _resolve_fallback_models(model: str, fallbacks: list) -> list[str] | None: 

2329 from litellm.router_utils.fallback_event_handlers import get_fallback_model_group 

2330 

2331 fallback_model_group, generic_fallback_idx = get_fallback_model_group( 

2332 fallbacks=fallbacks, 

2333 model_group=model, 

2334 ) 

2335 if fallback_model_group is not None: 

2336 return fallback_model_group 

2337 return fallbacks[generic_fallback_idx]["*"] if generic_fallback_idx is not None else None 

2338 

2339 @staticmethod 

2340 def _get_model_id_from_response(hidden_params: Mapping[str, object], data: Mapping[str, object]) -> str: 

2341 """Extract model_id from hidden_params with fallback to litellm_metadata.""" 

2342 model_id = hidden_params.get("model_id", None) or "" 

2343 if not model_id: 2343 ↛ 2347line 2343 didn't jump to line 2347 because the condition on line 2343 was always true

2344 litellm_metadata: Final = data.get("litellm_metadata") 

2345 model_info: Final = litellm_metadata.get("model_info") if isinstance(litellm_metadata, Mapping) else None 

2346 model_id = (model_info.get("id") or "") if isinstance(model_info, Mapping) else "" 

2347 return str(model_id) if model_id else "" 

2348 

2349 def _stream_response_headers( 

2350 self, 

2351 *, 

2352 hidden_params: Mapping[str, object], 

2353 user_api_key_dict: UserAPIKeyAuth, 

2354 logging_obj: LiteLLMLoggingObj, 

2355 version: str | None, 

2356 callback_headers: Mapping[str, str], 

2357 ) -> Mapping[str, str]: 

2358 """The streaming response headers describing `hidden_params`' deployment.""" 

2359 return MappingProxyType( 

2360 { 

2361 **ProxyBaseLLMRequestProcessing.get_custom_headers( 

2362 user_api_key_dict=user_api_key_dict, 

2363 call_id=logging_obj.litellm_call_id, 

2364 model_id=self._get_model_id_from_response(hidden_params, self.data), 

2365 cache_key=hidden_params.get("cache_key") or "", 

2366 api_base=hidden_params.get("api_base") or "", 

2367 version=version, 

2368 response_cost=hidden_params.get("response_cost") or "", 

2369 model_region=getattr(user_api_key_dict, "allowed_model_region", ""), 

2370 fastest_response_batch_completion=hidden_params.get("fastest_response_batch_completion"), 

2371 request_data=self.data, 

2372 hidden_params=hidden_params, 

2373 litellm_logging_obj=logging_obj, 

2374 **(hidden_params.get("additional_headers") or MappingProxyType({})), 

2375 ), 

2376 **callback_headers, 

2377 } 

2378 ) 

2379 

2380 @staticmethod 

2381 def _get_deployment_model_name( 

2382 litellm_logging_obj: LiteLLMLoggingObj | None, 

2383 ) -> str | None: 

2384 """Extract the underlying deployment model string (e.g. ``azure/gpt-4o``). 

2385 

2386 The router rewrites the response ``model`` field to the model-group alias 

2387 the client requested, so neither the response body nor the existing 

2388 headers expose the concrete deployment model. The router records it under 

2389 ``litellm_params`` metadata as ``deployment``, so read it back from there. 

2390 """ 

2391 litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None) 

2392 if not isinstance(litellm_params, dict): 

2393 return None 

2394 for key in ("litellm_metadata", "metadata"): 

2395 metadata = litellm_params.get(key, {}) or {} 

2396 deployment = metadata.get("deployment") 

2397 if deployment: 2397 ↛ 2398line 2397 didn't jump to line 2398 because the condition on line 2397 was never true

2398 return deployment 

2399 return None 

2400 

2401 @staticmethod 

2402 def _response_cost_from_logging_obj( 

2403 *, 

2404 response: Any, 

2405 logging_obj: LiteLLMLoggingObj, 

2406 ) -> float | str: 

2407 """ 

2408 Recover the response cost when the response never recorded one in its 

2409 ``_hidden_params``: Anthropic /v1/messages returns a TypedDict that cannot 

2410 hold the attribute at all, and Google :generateContent carries 

2411 ``_hidden_params`` but no synchronously-populated ``response_cost``. In both 

2412 cases the cost is read back from the logging object instead, recomputing from 

2413 the same calculator only when it has not been stored yet. 

2414 """ 

2415 stored_cost: Final = logging_obj.model_call_details.get("response_cost") 

2416 if isinstance(stored_cost, (int, float)): 

2417 return float(stored_cost) 

2418 recomputed_cost: Final = logging_obj._response_cost_calculator(result=response) 

2419 return recomputed_cost if isinstance(recomputed_cost, (int, float)) else "" 

2420 

2421 def _debug_log_request_payload(self) -> None: 

2422 """Log request payload at DEBUG level, truncating if too large.""" 

2423 if not verbose_proxy_logger.isEnabledFor(logging.DEBUG): 2423 ↛ 2425line 2423 didn't jump to line 2425 because the condition on line 2423 was always true

2424 return 

2425 _payload_str: Final = json.dumps(self.data, default=str) 

2426 if len(_payload_str) > MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG: 

2427 verbose_proxy_logger.debug( 

2428 "Request received by LiteLLM: payload too large to log (%d bytes, limit %d). Keys: %s", 

2429 len(_payload_str), 

2430 MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG, 

2431 (list(self.data.keys()) if isinstance(self.data, dict) else type(self.data).__name__), 

2432 ) 

2433 else: 

2434 verbose_proxy_logger.debug( 

2435 "Request received by LiteLLM:\n%s", 

2436 _payload_str, 

2437 ) 

2438 

2439 async def base_process_llm_request( 

2440 self, 

2441 request: Request, 

2442 fastapi_response: Response, 

2443 user_api_key_dict: UserAPIKeyAuth, 

2444 route_type: ProxyRouteType, 

2445 proxy_logging_obj: ProxyLogging, 

2446 general_settings: dict[str, object], 

2447 proxy_config: ProxyConfig, 

2448 select_data_generator: Callable[..., object] | None = None, 

2449 llm_router: Router | None = None, 

2450 model: str | None = None, 

2451 user_model: str | None = None, 

2452 user_temperature: float | None = None, 

2453 user_request_timeout: float | None = None, 

2454 user_max_tokens: int | None = None, 

2455 user_api_base: str | None = None, 

2456 version: str | None = None, 

2457 is_streaming_request: bool | None = False, 

2458 contents: list[object] | None = None, 

2459 skip_pre_call_logic: bool = False, 

2460 ) -> Any: 

2461 """Run the request, sending SSE keepalives while the upstream is still silent. 

2462 

2463 Everything below this point, the upstream call included, happens before the 

2464 proxy can write a byte, so a slow time-to-first-token leaves the response 

2465 idle. See ``open_sse_before_first_byte``; unwrapped unless an operator sets 

2466 ``litellm_settings.sse_keepalive_ping_interval_seconds``. 

2467 """ 

2468 

2469 async def _audit_late_failure(exc: Exception) -> HTTPException | None: 

2470 # Once a keepalive is on the wire this can no longer raise, so the 

2471 # caller's `except` never runs its own post_call_failure_hook. 

2472 return await proxy_logging_obj.post_call_failure_hook( 

2473 user_api_key_dict=user_api_key_dict, 

2474 original_exception=exc, 

2475 request_data=self.data, 

2476 ) 

2477 

2478 return await open_sse_before_first_byte( 

2479 self._process_llm_request( 

2480 request=request, 

2481 fastapi_response=fastapi_response, 

2482 user_api_key_dict=user_api_key_dict, 

2483 route_type=route_type, 

2484 proxy_logging_obj=proxy_logging_obj, 

2485 general_settings=general_settings, 

2486 proxy_config=proxy_config, 

2487 select_data_generator=select_data_generator, 

2488 llm_router=llm_router, 

2489 model=model, 

2490 user_model=user_model, 

2491 user_temperature=user_temperature, 

2492 user_request_timeout=user_request_timeout, 

2493 user_max_tokens=user_max_tokens, 

2494 user_api_base=user_api_base, 

2495 version=version, 

2496 is_streaming_request=is_streaming_request, 

2497 contents=contents, 

2498 skip_pre_call_logic=skip_pre_call_logic, 

2499 ), 

2500 ping_interval_seconds=ttft_keepalive_interval(self.data, llm_router), 

2501 on_late_failure=_audit_late_failure, 

2502 ) 

2503 

2504 async def _process_llm_request( 

2505 self, 

2506 request: Request, 

2507 fastapi_response: Response, 

2508 user_api_key_dict: UserAPIKeyAuth, 

2509 route_type: ProxyRouteType, 

2510 proxy_logging_obj: ProxyLogging, 

2511 general_settings: dict[str, object], 

2512 proxy_config: ProxyConfig, 

2513 select_data_generator: Callable[..., object] | None = None, 

2514 llm_router: Router | None = None, 

2515 model: str | None = None, 

2516 user_model: str | None = None, 

2517 user_temperature: float | None = None, 

2518 user_request_timeout: float | None = None, 

2519 user_max_tokens: int | None = None, 

2520 user_api_base: str | None = None, 

2521 version: str | None = None, 

2522 is_streaming_request: bool | None = False, 

2523 contents: list[object] | None = None, # Add contents parameter 

2524 skip_pre_call_logic: bool = False, 

2525 ) -> Any: 

2526 """ 

2527 Common request processing logic for both chat completions and responses API endpoints 

2528 """ 

2529 client_model: Final = get_client_requested_model(request) or self.data.get("model") 

2530 requested_model_from_client: Final[str | None] = client_model if isinstance(client_model, str) else None 

2531 self._debug_log_request_payload() 

2532 

2533 if skip_pre_call_logic: 2533 ↛ 2534line 2533 didn't jump to line 2534 because the condition on line 2533 was never true

2534 logging_obj = self.data.get("litellm_logging_obj") 

2535 if logging_obj is None: 

2536 raise ValueError( 

2537 "skip_pre_call_logic=True requires litellm_logging_obj to be set in data. " 

2538 "Ensure common_processing_pre_call_logic was called before using this parameter." 

2539 ) 

2540 else: 

2541 self.data, logging_obj = await self._pre_call_with_fallbacks( 

2542 request=request, 

2543 general_settings=general_settings, 

2544 proxy_logging_obj=proxy_logging_obj, 

2545 user_api_key_dict=user_api_key_dict, 

2546 version=version, 

2547 proxy_config=proxy_config, 

2548 user_model=user_model, 

2549 user_temperature=user_temperature, 

2550 user_request_timeout=user_request_timeout, 

2551 user_max_tokens=user_max_tokens, 

2552 user_api_base=user_api_base, 

2553 model=model, 

2554 route_type=route_type, 

2555 llm_router=llm_router, 

2556 ) 

2557 

2558 # Defer async logging when post-call guardrails are configured so the 

2559 # StandardLoggingPayload is built after guardrails write to metadata. 

2560 # Cache the result to avoid scanning litellm.callbacks twice. 

2561 _post_call_guardrails_active: Final = self._has_post_call_guardrails() 

2562 

2563 # Non-streaming: defer the create_task in wrapper_async so the 

2564 # SLP is built after guardrails write to metadata. Streaming 

2565 # uses a separate closure mechanism (see below). 

2566 # 

2567 # Edge case: if _is_streaming_request is False but the response 

2568 # turns out to be a CustomStreamWrapper (rare provider behavior), 

2569 # wrapper_async exits early before the _defer_async_logging block 

2570 # so _enqueue_deferred_logging is never stored — the finally 

2571 # block is a no-op. The CSW path handles this correctly via 

2572 # _on_deferred_stream_complete, which fires its own logging. 

2573 if _post_call_guardrails_active and not self._is_streaming_request( 2573 ↛ 2576line 2573 didn't jump to line 2576 because the condition on line 2573 was never true

2574 data=self.data, is_streaming_request=is_streaming_request 

2575 ): 

2576 logging_obj._defer_async_logging = True 

2577 

2578 tasks: Final = [] 

2579 # Start the moderation check (during_call_hook) as early as possible 

2580 # This gives it a head start to mask/validate input while the proxy handles routing 

2581 tasks.append( 

2582 asyncio.create_task( 

2583 proxy_logging_obj.during_call_hook( 

2584 data=self.data, 

2585 user_api_key_dict=user_api_key_dict, 

2586 call_type=route_type, 

2587 ) 

2588 ) 

2589 ) 

2590 

2591 # Pass contents if provided 

2592 if contents: 2592 ↛ 2593line 2592 didn't jump to line 2593 because the condition on line 2592 was never true

2593 self.data["contents"] = contents 

2594 

2595 ### ROUTE THE REQUEST ### 

2596 # Do not change this - it should be a constant time fetch - ALWAYS 

2597 llm_call: Final = await route_request( 

2598 data=self.data, 

2599 route_type=route_type, 

2600 llm_router=llm_router, 

2601 user_model=user_model, 

2602 user_api_key_dict=user_api_key_dict, 

2603 ) 

2604 llm_call_task: Final = asyncio.create_task(with_proxy_compaction_executor(llm_call, request)) 

2605 tasks.append(llm_call_task) 

2606 

2607 llm_responses: Final = asyncio.gather(*tasks) # run the moderation check in parallel to the actual llm api call 

2608 

2609 try: 

2610 if general_settings.get("cancel_on_disconnect", False): 2610 ↛ 2611line 2610 didn't jump to line 2611 because the condition on line 2610 was never true

2611 responses = await _await_llm_call_cancelling_on_disconnect(request, llm_responses) 

2612 else: 

2613 responses = await llm_responses 

2614 finally: 

2615 await _cancel_pending_gather_tasks(tasks) 

2616 

2617 response = responses[1] 

2618 

2619 _exception_raised = False 

2620 try: 

2621 hidden_params = get_hidden_params_dict(response) 

2622 model_id: Final = self._get_model_id_from_response(hidden_params, self.data) 

2623 

2624 cache_key, api_base, response_cost = ( 

2625 hidden_params.get("cache_key", None) or "", 

2626 hidden_params.get("api_base", None) or "", 

2627 hidden_params.get("response_cost", None) or "", 

2628 ) 

2629 fastest_response_batch_completion, additional_headers = ( 

2630 hidden_params.get("fastest_response_batch_completion", None), 

2631 hidden_params.get("additional_headers", {}) or {}, 

2632 ) 

2633 

2634 # Post Call Processing 

2635 if llm_router is not None: 2635 ↛ 2637line 2635 didn't jump to line 2637 because the condition on line 2635 was always true

2636 self.data["deployment"] = llm_router.get_deployment(model_id=model_id) 

2637 asyncio.create_task( 

2638 proxy_logging_obj.update_request_status( 

2639 litellm_call_id=self.data.get("litellm_call_id", ""), 

2640 status="success", 

2641 ) 

2642 ) 

2643 if self._is_streaming_request( 2643 ↛ 2646line 2643 didn't jump to line 2646 because the condition on line 2643 was never true

2644 data=self.data, is_streaming_request=is_streaming_request 

2645 ) or self._is_streaming_response(response): # use generate_responses to stream responses 

2646 selected_data_generator: AsyncGenerator[str, None] | None = None 

2647 # Call response headers hook for streaming success 

2648 stream_callback_headers: Final = await proxy_logging_obj.post_call_response_headers_hook( 

2649 data=self.data, 

2650 user_api_key_dict=user_api_key_dict, 

2651 response=response, 

2652 request_headers=dict(request.headers), 

2653 ) 

2654 custom_headers: Final = self._stream_response_headers( 

2655 hidden_params=hidden_params, 

2656 user_api_key_dict=user_api_key_dict, 

2657 logging_obj=logging_obj, 

2658 version=version, 

2659 callback_headers=stream_callback_headers or MappingProxyType({}), 

2660 ) 

2661 

2662 async def refresh_stream_headers() -> Mapping[str, str]: 

2663 """`custom_headers` rebuilt for whichever deployment served the stream.""" 

2664 return self._stream_response_headers( 

2665 hidden_params=( 

2666 get_hidden_params_dict(response) 

2667 if getattr(response, "fallback_headers_adopted", False) 

2668 else hidden_params 

2669 ), 

2670 user_api_key_dict=user_api_key_dict, 

2671 logging_obj=logging_obj, 

2672 version=version, 

2673 callback_headers=stream_callback_headers or MappingProxyType({}), 

2674 ) 

2675 

2676 # Preserve the original client-requested model (pre-alias mapping) for downstream 

2677 # streaming generators. Pre-call processing can rewrite `self.data["model"]` for 

2678 # aliasing/routing, but the OpenAI-compatible response `model` field should reflect 

2679 # what the client sent. 

2680 if requested_model_from_client: 

2681 self.data["_litellm_client_requested_model"] = requested_model_from_client 

2682 

2683 if _post_call_guardrails_active: 

2684 self._arm_deferred_stream_dispatch( 

2685 response=response, 

2686 route_type=route_type, 

2687 user_api_key_dict=user_api_key_dict, 

2688 logging_obj=logging_obj, 

2689 ) 

2690 

2691 if route_type == "allm_passthrough_route": 

2692 upstream_response_headers: Final = getattr(response, "headers", None) 

2693 streaming_headers: Final = ( 

2694 ProxyBaseLLMRequestProcessing._merge_passthrough_streaming_headers( 

2695 response_headers=upstream_response_headers, 

2696 custom_headers=custom_headers, 

2697 ) 

2698 if upstream_response_headers is not None 

2699 else custom_headers 

2700 ) 

2701 

2702 # Check if response is an async generator 

2703 if self._is_streaming_response(response): 

2704 if asyncio.iscoroutine(response): 

2705 generator = await response 

2706 else: 

2707 generator = response 

2708 

2709 if ( 

2710 self._has_post_call_guardrails_for_passthrough() 

2711 and self._passthrough_endpoint_has_stream_guardrail_handler() 

2712 ): 

2713 body_bytes: Final = b"".join([chunk async for chunk in generator]) 

2714 modified_bytes: Final = await self._handle_event_stream_allm_passthrough_route( 

2715 body_bytes=body_bytes, 

2716 proxy_logging_obj=proxy_logging_obj, 

2717 user_api_key_dict=user_api_key_dict, 

2718 ) 

2719 response_headers: Final = { 

2720 k: v for k, v in custom_headers.items() if k.lower() != "content-length" 

2721 } 

2722 return Response( 

2723 content=modified_bytes, 

2724 status_code=status.HTTP_200_OK, 

2725 media_type=self._passthrough_event_stream_media_type(), 

2726 headers=response_headers, 

2727 ) 

2728 

2729 # For passthrough routes, stream directly without error parsing 

2730 # since we're dealing with raw binary data (e.g., AWS event streams) 

2731 return _UpstreamClosingStreamingResponse( 

2732 content=generator, # pyright: ignore[reportArgumentType] # generator-configured StreamingResponse 

2733 status_code=getattr(response, "status_code", status.HTTP_200_OK), 

2734 media_type=self._passthrough_event_stream_media_type(), 

2735 headers=streaming_headers, 

2736 ) 

2737 else: 

2738 _early = await self._handle_non_streaming_allm_passthrough_route( 

2739 response=response, 

2740 proxy_logging_obj=proxy_logging_obj, 

2741 user_api_key_dict=user_api_key_dict, 

2742 custom_headers=custom_headers, 

2743 request_headers=dict(request.headers), 

2744 ) 

2745 if _early is not None: 

2746 return _early 

2747 return StreamingResponse( 

2748 content=response.aiter_bytes(), 

2749 status_code=response.status_code, 

2750 headers=streaming_headers, 

2751 ) 

2752 elif route_type == "anthropic_messages": 

2753 # Check if response is actually a streaming response (async generator) 

2754 # Non-streaming responses (dict) should be returned directly 

2755 # This handles cases like websearch_interception agentic loop 

2756 # which returns a non-streaming dict even for streaming requests 

2757 if self._is_streaming_response(response): 

2758 self._arm_detached_stream_failure_hook( 

2759 logging_obj=logging_obj, 

2760 user_api_key_dict=user_api_key_dict, 

2761 proxy_logging_obj=proxy_logging_obj, 

2762 ) 

2763 selected_data_generator = ProxyBaseLLMRequestProcessing.async_sse_data_generator( 

2764 response=response, 

2765 user_api_key_dict=user_api_key_dict, 

2766 request_data=self.data, 

2767 proxy_logging_obj=proxy_logging_obj, 

2768 request=request, 

2769 restamp_model=( 

2770 None if _should_return_raw_model_name(self.data) else requested_model_from_client 

2771 ), 

2772 ) 

2773 selected_data_generator = wrap_sse_stream_with_keepalive_pings( 

2774 stream=selected_data_generator, 

2775 ping_interval_seconds=litellm.anthropic_sse_ping_interval_seconds, 

2776 ) 

2777 # Non-streaming response - fall through to normal response handling 

2778 elif select_data_generator: 

2779 selected_data_generator = select_data_generator( 

2780 response=response, 

2781 user_api_key_dict=user_api_key_dict, 

2782 request_data=self.data, 

2783 request=request, 

2784 ) 

2785 if route_type == "aresponses": 

2786 # Streaming /v1/responses returns here without 

2787 # reaching the non-streaming ownership tail below. 

2788 # Wrap the SSE generator so container ownership is 

2789 # written once the upstream iterator finishes 

2790 # assembling ``completed_response`` — otherwise 

2791 # code-interpreter containers created during the 

2792 # stream stay unregistered and follow-up file API 

2793 # calls 403. Covers the background-polling path 

2794 # too, which loops ``body_iterator`` end-to-end. 

2795 selected_data_generator = ( 

2796 ProxyBaseLLMRequestProcessing._wrap_responses_stream_for_container_ownership( 

2797 original_stream_response=response, 

2798 wrapped_generator=selected_data_generator, 

2799 user_api_key_dict=user_api_key_dict, 

2800 ) 

2801 ) 

2802 if selected_data_generator is not None: 

2803 return await create_response( 

2804 generator=selected_data_generator, 

2805 media_type="text/event-stream", 

2806 headers=custom_headers, 

2807 request=request, 

2808 refresh_headers=refresh_stream_headers, 

2809 general_settings=general_settings, 

2810 ) 

2811 

2812 ### CALL HOOKS ### - modify outgoing data 

2813 # If we reach here with a streaming closure still set, it means 

2814 # no early-return route consumed the CSW (hypothetical fallthrough). 

2815 # Clear the closure so guardrails run inline as before — this 

2816 # preserves blocking behavior and avoids double invocation. 

2817 if getattr(logging_obj, "_on_deferred_stream_complete", None): 2817 ↛ 2818line 2817 didn't jump to line 2818 because the condition on line 2817 was never true

2818 logging_obj._on_deferred_stream_complete = None 

2819 

2820 if route_type == "allm_passthrough_route": 2820 ↛ 2821line 2820 didn't jump to line 2821 because the condition on line 2820 was never true

2821 _non_streaming_custom_headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers( 

2822 user_api_key_dict=user_api_key_dict, 

2823 call_id=logging_obj.litellm_call_id, 

2824 model_id=model_id, 

2825 cache_key=cache_key, 

2826 api_base=api_base, 

2827 version=version, 

2828 response_cost=response_cost, 

2829 model_region=getattr(user_api_key_dict, "allowed_model_region", ""), 

2830 fastest_response_batch_completion=fastest_response_batch_completion, 

2831 request_data=self.data, 

2832 hidden_params=hidden_params, 

2833 litellm_logging_obj=logging_obj, 

2834 **additional_headers, 

2835 ) 

2836 _early = await self._handle_non_streaming_allm_passthrough_route( 

2837 response=response, 

2838 proxy_logging_obj=proxy_logging_obj, 

2839 user_api_key_dict=user_api_key_dict, 

2840 custom_headers=_non_streaming_custom_headers, 

2841 request_headers=dict(request.headers), 

2842 ) 

2843 if _early is not None: 

2844 return _early 

2845 

2846 response = await proxy_logging_obj.post_call_success_hook( 

2847 data=self.data, 

2848 user_api_key_dict=user_api_key_dict, 

2849 response=response, 

2850 ) 

2851 record_served_output_texts(logging_obj.model_call_details, served_output_texts(response)) 

2852 except Exception: 

2853 _exception_raised = True 

2854 raise 

2855 finally: 

2856 ProxyBaseLLMRequestProcessing._flush_deferred_async_logging( 

2857 logging_obj=logging_obj, 

2858 exception_raised=_exception_raised, 

2859 ) 

2860 

2861 # Streaming cleanup: if an exception occurred AND the deferred 

2862 # streaming closure is still set, no streaming route will 

2863 # consume the CSW — the closure is orphaned. Clear it and 

2864 # fire logging directly to avoid silent loss. 

2865 # 

2866 # On normal streaming returns the closure must stay: CSW calls 

2867 # it at stream end. _exception_raised is function-scoped and 

2868 # immune to outer exception context, avoiding false positives. 

2869 if _exception_raised: 2869 ↛ 2870line 2869 didn't jump to line 2870 because the condition on line 2869 was never true

2870 _deferred_fn: Final = getattr(logging_obj, "_on_deferred_stream_complete", None) 

2871 if _deferred_fn is not None: 

2872 logging_obj._on_deferred_stream_complete = None 

2873 try: 

2874 asyncio.create_task( 

2875 logging_obj.dispatch_success_handlers( 

2876 response, 

2877 cache_hit=None, 

2878 start_time=None, 

2879 end_time=None, 

2880 prefer_async_handlers=True, 

2881 ) 

2882 ) 

2883 except Exception as e: 

2884 verbose_proxy_logger.exception("Error in orphaned streaming async logging: %s", e) 

2885 

2886 hidden_params = get_hidden_params_dict(response) # get any updated response headers 

2887 additional_headers = hidden_params.get("additional_headers", {}) or {} 

2888 

2889 recover_response_cost: Final = not response_cost and hidden_params.get("response_cost") is None 

2890 computed_cost_for_headers: Final = ( 

2891 self._response_cost_from_logging_obj(response=response, logging_obj=logging_obj) or "" 

2892 if recover_response_cost 

2893 else response_cost 

2894 ) 

2895 llm_cost_for_headers: Final = ( 

2896 0.0 

2897 if is_unbilled_non_inference_call_from_params(logging_obj.call_type, logging_obj.litellm_params, response) 

2898 else computed_cost_for_headers 

2899 ) 

2900 _, request_metadata_bucket = get_or_create_metadata_bucket(self.data) 

2901 guardrail_cost_for_headers: Final = guardrail_information_cost( 

2902 request_metadata_bucket.get("standard_logging_guardrail_information") 

2903 ) 

2904 response_cost_for_headers: Final = ( 

2905 (llm_cost_for_headers if isinstance(llm_cost_for_headers, (int, float)) else 0.0) 

2906 + guardrail_cost_for_headers 

2907 if guardrail_cost_for_headers > 0 

2908 else llm_cost_for_headers 

2909 ) 

2910 

2911 # Always return the client-requested model name (not provider-prefixed internal identifiers) 

2912 # for OpenAI-compatible responses. 

2913 if requested_model_from_client: 2913 ↛ 2914line 2913 didn't jump to line 2914 because the condition on line 2913 was never true

2914 _override_openai_response_model( 

2915 response_obj=response, 

2916 requested_model=requested_model_from_client, 

2917 log_context=f"litellm_call_id={logging_obj.litellm_call_id}", 

2918 return_raw_model_name=_should_return_raw_model_name(self.data), 

2919 ) 

2920 

2921 fastapi_response.headers.update( 

2922 ProxyBaseLLMRequestProcessing.get_custom_headers( 

2923 user_api_key_dict=user_api_key_dict, 

2924 call_id=logging_obj.litellm_call_id, 

2925 model_id=model_id, 

2926 cache_key=cache_key, 

2927 api_base=api_base, 

2928 version=version, 

2929 response_cost=response_cost_for_headers, 

2930 model_region=getattr(user_api_key_dict, "allowed_model_region", ""), 

2931 fastest_response_batch_completion=fastest_response_batch_completion, 

2932 request_data=self.data, 

2933 hidden_params=hidden_params, 

2934 litellm_logging_obj=logging_obj, 

2935 **additional_headers, 

2936 ) 

2937 ) 

2938 

2939 if isinstance(response, dict): 

2940 response.pop("_hidden_params", None) 

2941 

2942 if include_guardrail_response_requested(self.data): 2942 ↛ 2943line 2942 didn't jump to line 2943 because the condition on line 2942 was never true

2943 response = attach_guardrail_information( # rebind-ok: response tail rebinds the copied response 

2944 response=response, request_data=self.data 

2945 ) 

2946 

2947 # Call response headers hook for non-streaming success 

2948 callback_headers = await proxy_logging_obj.post_call_response_headers_hook( 

2949 data=self.data, 

2950 user_api_key_dict=user_api_key_dict, 

2951 response=response, 

2952 request_headers=dict(request.headers), 

2953 ) 

2954 if callback_headers: 2954 ↛ 2955line 2954 didn't jump to line 2955 because the condition on line 2954 was never true

2955 fastapi_response.headers.update(callback_headers) 

2956 

2957 await check_response_size_is_safe(response=response) 

2958 

2959 if route_type in {"aresponses", "aget_responses"}: 2959 ↛ 2960line 2959 didn't jump to line 2960 because the condition on line 2959 was never true

2960 await ProxyBaseLLMRequestProcessing._record_container_owners_from_responses_if_needed( 

2961 response=response, 

2962 user_api_key_dict=user_api_key_dict, 

2963 ) 

2964 

2965 return response 

2966 

2967 @staticmethod 

2968 async def _record_container_owners_from_responses_if_needed( 

2969 response: object, 

2970 user_api_key_dict: UserAPIKeyAuth, 

2971 ) -> None: 

2972 """Register code-interpreter containers so follow-up file APIs pass ownership checks.""" 

2973 from litellm.proxy.container_endpoints.ownership import ( 

2974 record_container_owners_from_responses_response, 

2975 ) 

2976 

2977 if response is None: 

2978 return 

2979 

2980 try: 

2981 await record_container_owners_from_responses_response( 

2982 response=response, 

2983 user_api_key_dict=user_api_key_dict, 

2984 ) 

2985 except Exception as e: 

2986 verbose_proxy_logger.exception( 

2987 "Container ownership recording failed after responses call: %s", 

2988 e, 

2989 ) 

2990 

2991 @staticmethod 

2992 def _extract_completed_responses_response(stream_response: object) -> object: 

2993 """Pull the assembled ``ResponsesAPIResponse`` off a streaming iterator. 

2994 

2995 ``ResponsesAPIStreamingIterator`` stores the terminal stream event 

2996 (``response.completed`` / ``response.incomplete`` / ``response.failed``) 

2997 in ``completed_response``; the actual response body hangs off 

2998 that event's ``.response`` attribute. Some iterators store the 

2999 ``ResponsesAPIResponse`` directly. Handle both shapes so the 

3000 container-ownership recording path can walk ``.output`` either way. 

3001 """ 

3002 completed: Final = attribute_of(stream_response, "completed_response") 

3003 if completed is None: 

3004 return None 

3005 response_obj: Final = attribute_of(completed, "response") 

3006 if response_obj is not None: 

3007 return response_obj 

3008 return completed 

3009 

3010 @staticmethod 

3011 async def _wrap_responses_stream_for_container_ownership( 

3012 original_stream_response: object, 

3013 wrapped_generator: Any, 

3014 user_api_key_dict: UserAPIKeyAuth, 

3015 ): 

3016 """Forward SSE chunks, then record container ownership at stream end. 

3017 

3018 Streaming ``/v1/responses`` short-circuits out of 

3019 ``base_process_llm_request`` before the non-streaming ownership 

3020 tail runs, so without this wrap the 

3021 ``LiteLLM_ManagedObjectTable`` row for any container created 

3022 during the stream is never written and follow-up file API calls 

3023 return 403. 

3024 """ 

3025 try: 

3026 async for chunk in wrapped_generator: 

3027 yield chunk 

3028 finally: 

3029 try: 

3030 completed_obj: Final = ProxyBaseLLMRequestProcessing._extract_completed_responses_response( 

3031 original_stream_response 

3032 ) 

3033 if completed_obj is not None: 

3034 await ProxyBaseLLMRequestProcessing._record_container_owners_from_responses_if_needed( 

3035 response=completed_obj, 

3036 user_api_key_dict=user_api_key_dict, 

3037 ) 

3038 else: 

3039 # Silent skip caused #30210: the proxy's Router wrapper 

3040 # of the responses streaming iterator wasn't propagating 

3041 # ``completed_response``, so this hook recorded nothing 

3042 # and follow-up /v1/containers/<id>/files calls 403'd 

3043 # for non-admin keys with no proxy-side hint. Log a 

3044 # warning so future regressions of the same shape 

3045 # surface in operator logs. 

3046 verbose_proxy_logger.warning( 

3047 "Container ownership recording skipped on streaming " 

3048 "/v1/responses: no completed_response on stream " 

3049 "iterator %s. If this stream created any tool " 

3050 "container (e.g. code_interpreter), follow-up " 

3051 "/v1/containers/<id>/files calls will 403 for " 

3052 "non-admin keys.", 

3053 type(original_stream_response).__name__, 

3054 ) 

3055 except Exception as e: 

3056 verbose_proxy_logger.exception( 

3057 "Container ownership recording failed after streaming responses call: %s", 

3058 e, 

3059 ) 

3060 

3061 async def base_passthrough_process_llm_request( 

3062 self, 

3063 request: Request, 

3064 fastapi_response: Response, 

3065 user_api_key_dict: UserAPIKeyAuth, 

3066 proxy_logging_obj: ProxyLogging, 

3067 general_settings: dict, 

3068 proxy_config: ProxyConfig, 

3069 select_data_generator: Callable, 

3070 llm_router: Router | None = None, 

3071 model: str | None = None, 

3072 user_model: str | None = None, 

3073 user_temperature: float | None = None, 

3074 user_request_timeout: float | None = None, 

3075 user_max_tokens: int | None = None, 

3076 user_api_base: str | None = None, 

3077 version: str | None = None, 

3078 ): 

3079 from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( 

3080 HttpPassThroughEndpointHelpers, 

3081 ) 

3082 

3083 result: Final = await self.base_process_llm_request( 

3084 request=request, 

3085 fastapi_response=fastapi_response, 

3086 user_api_key_dict=user_api_key_dict, 

3087 route_type="allm_passthrough_route", 

3088 proxy_logging_obj=proxy_logging_obj, 

3089 llm_router=llm_router, 

3090 general_settings=general_settings, 

3091 proxy_config=proxy_config, 

3092 select_data_generator=select_data_generator, 

3093 model=model, 

3094 user_model=user_model, 

3095 user_temperature=user_temperature, 

3096 user_request_timeout=user_request_timeout, 

3097 user_max_tokens=user_max_tokens, 

3098 user_api_base=user_api_base, 

3099 version=version, 

3100 ) 

3101 

3102 # Check if result is actually a streaming response by inspecting its type 

3103 if isinstance(result, StreamingResponse): 

3104 return result 

3105 

3106 # base_process_llm_request may return a FastAPI Response directly after 

3107 # post-call guardrails buffer and rewrite JSON (e.g. Bedrock Converse passthrough). 

3108 if isinstance(result, Response): 

3109 return result 

3110 

3111 upstream: Final = _as_upstream_response(result) 

3112 content: Final = await upstream.aread() 

3113 return Response( 

3114 content=content, 

3115 status_code=upstream.status_code, 

3116 headers=HttpPassThroughEndpointHelpers.get_response_headers( 

3117 headers=upstream.headers, 

3118 custom_headers=dict(fastapi_response.headers), 

3119 ), 

3120 ) 

3121 

3122 def _arm_detached_stream_failure_hook( 

3123 self, 

3124 logging_obj: LiteLLMLoggingObj, 

3125 user_api_key_dict: "UserAPIKeyAuth", 

3126 proxy_logging_obj: ProxyLogging, 

3127 ) -> None: 

3128 """Let a stream that fails after the client left still reach ``post_call_failure_hook``. 

3129 

3130 The client-facing generator reports a mid-stream failure itself, but once 

3131 the client disconnects that generator is gone and the detached upstream 

3132 drain is the only code that sees the provider error. It fires this closure 

3133 so the failed spend is still written and the budget reservation released; 

3134 a replacement error the hook raises has no client left to reach. 

3135 """ 

3136 request_data: Final = self.data 

3137 

3138 async def _on_detached_stream_failure(exc: Exception) -> None: 

3139 try: 

3140 await proxy_logging_obj.post_call_failure_hook( 

3141 user_api_key_dict=user_api_key_dict, 

3142 original_exception=exc, 

3143 request_data=request_data, 

3144 ) 

3145 except HTTPException: 

3146 return 

3147 

3148 logging_obj._on_detached_stream_failure = _on_detached_stream_failure 

3149 

3150 def _is_streaming_response(self, response: object) -> bool: 

3151 """ 

3152 Check if the response object is actually a streaming response by inspecting its type. 

3153 

3154 This uses standard Python inspection to detect streaming/async iterator objects 

3155 rather than relying on specific wrapper classes. 

3156 """ 

3157 import inspect 

3158 from collections.abc import AsyncGenerator, AsyncIterator 

3159 

3160 # Check if it's an async generator (most reliable) 

3161 if inspect.isasyncgen(response): 3161 ↛ 3162line 3161 didn't jump to line 3162 because the condition on line 3161 was never true

3162 return True 

3163 

3164 # Check if it implements the async iterator protocol 

3165 if isinstance(response, (AsyncIterator, AsyncGenerator)): 3165 ↛ 3166line 3165 didn't jump to line 3166 because the condition on line 3165 was never true

3166 return True 

3167 

3168 return False 

3169 

3170 def _is_streaming_request(self, data: dict, is_streaming_request: bool | None = False) -> bool: 

3171 """ 

3172 Check if the request is a streaming request. 

3173 

3174 1. is_streaming_request is a dynamic param passed in 

3175 2. if "stream" in data and data["stream"] is True 

3176 """ 

3177 if is_streaming_request is True: 3177 ↛ 3178line 3177 didn't jump to line 3178 because the condition on line 3177 was never true

3178 return True 

3179 if "stream" in data and data["stream"] is True: 3179 ↛ 3180line 3179 didn't jump to line 3180 because the condition on line 3179 was never true

3180 return True 

3181 return False 

3182 

3183 @staticmethod 

3184 def _has_post_call_guardrails() -> bool: 

3185 """ 

3186 True when a guardrail explicitly registers post_call. event_hook=None 

3187 matches all hooks in should_run_guardrail but must not defer async logging 

3188 on non-streaming /chat/completions (no post_call_success_hook flush path). 

3189 """ 

3190 for cb in litellm.callbacks: 

3191 if not isinstance(cb, CustomGuardrail): 3191 ↛ 3193line 3191 didn't jump to line 3193 because the condition on line 3191 was always true

3192 continue 

3193 if cb.event_hook is None: 

3194 continue 

3195 if cb._event_hook_is_event_type(GuardrailEventHooks.post_call): 

3196 return True 

3197 return False 

3198 

3199 def _has_post_call_guardrails_for_passthrough(self) -> bool: 

3200 """ 

3201 True when a post_call guardrail will actually run for THIS request. 

3202 

3203 Mirrors the gate in ProxyLogging.post_call_success_hook 

3204 (should_run_guardrail against the request's merged guardrails) so that a 

3205 guardrail registered globally but not configured for this key/team does 

3206 not force the passthrough stream to be buffered into a single 

3207 non-streaming response. An event_hook=None guardrail still counts here 

3208 because should_run_guardrail treats it as matching every hook. 

3209 """ 

3210 from litellm.proxy.proxy_server import llm_router 

3211 from litellm.proxy.utils import _check_and_merge_model_level_guardrails 

3212 

3213 guardrail_data: Final = _check_and_merge_model_level_guardrails(data=self.data, llm_router=llm_router) 

3214 for cb in litellm.callbacks: 

3215 if not isinstance(cb, CustomGuardrail): 

3216 continue 

3217 if cb.should_run_guardrail( 

3218 data=guardrail_data, 

3219 event_type=GuardrailEventHooks.post_call, 

3220 ): 

3221 return True 

3222 return False 

3223 

3224 def _passthrough_endpoint_has_stream_guardrail_handler(self) -> bool: 

3225 """ 

3226 True when the resolved passthrough provider AND endpoint have an 

3227 event-stream guardrail handler that can rewrite buffered frames. Only such 

3228 endpoints may have their stream buffered for post-call guardrails; every 

3229 other endpoint must keep streaming so the response is not silently turned 

3230 into a non-streaming body when no content modification would occur (e.g. 

3231 Bedrock invoke-with-response-stream, whose frames the Converse handler 

3232 leaves untouched). 

3233 """ 

3234 from litellm.llms.pass_through.guardrail_translation.handler import ( 

3235 LlmPassthroughRouteHandler, 

3236 ) 

3237 

3238 return LlmPassthroughRouteHandler.supports_event_stream_de_anonymization( 

3239 self.data.get("custom_llm_provider"), 

3240 self.data.get("endpoint"), 

3241 ) 

3242 

3243 def _passthrough_event_stream_media_type(self) -> str | None: 

3244 """ 

3245 Content-type for a passthrough event-stream response, resolved from the 

3246 provider handler so the proxy stays provider-agnostic. Mirrors the 

3247 upstream content-type the non-streaming path forwards, since the 

3248 streaming generator carries no headers of its own. Used for both the 

3249 buffered (guardrail-rewritten) and the unbuffered relay paths so 

3250 clients that enforce the event-stream content-type (e.g. Claude Code on 

3251 Bedrock invoke-with-response-stream) see the correct header instead of 

3252 no content-type at all, which they fall back to reading as 

3253 application/octet-stream. Returns None for providers with no 

3254 event-stream media type, leaving the response headers unchanged. 

3255 """ 

3256 from litellm.llms.pass_through.guardrail_translation.handler import ( 

3257 LlmPassthroughRouteHandler, 

3258 ) 

3259 

3260 return LlmPassthroughRouteHandler.event_stream_media_type(self.data.get("custom_llm_provider")) 

3261 

3262 async def _handle_non_streaming_allm_passthrough_route( 

3263 self, 

3264 response: _UpstreamHttpResponse, 

3265 proxy_logging_obj: "ProxyLogging", 

3266 user_api_key_dict: "UserAPIKeyAuth", 

3267 custom_headers: Mapping[str, str], 

3268 request_headers: dict[str, str], 

3269 ) -> Response | None: 

3270 if not self._has_post_call_guardrails_for_passthrough(): 

3271 return None 

3272 

3273 import json as _json 

3274 

3275 from litellm.llms.pass_through.guardrail_translation.handler import ( 

3276 LlmPassthroughRouteHandler, 

3277 ) 

3278 from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( 

3279 HttpPassThroughEndpointHelpers, 

3280 ) 

3281 

3282 upstream: Final = _as_upstream_response(response) 

3283 try: 

3284 response_status: Final[int] = upstream.status_code 

3285 content_type: Final[str] = _as_header_reader(upstream.headers).get("content-type", "") 

3286 except AttributeError: 

3287 return None 

3288 

3289 if response_status >= 300: 

3290 return None 

3291 

3292 is_event_stream: Final = LlmPassthroughRouteHandler.is_event_stream_response( 

3293 self.data.get("custom_llm_provider"), content_type 

3294 ) 

3295 if not is_event_stream and "application/json" not in content_type: 

3296 return None 

3297 

3298 response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( 

3299 headers=upstream.headers, 

3300 custom_headers=custom_headers, 

3301 ) 

3302 callback_headers: Final = await proxy_logging_obj.post_call_response_headers_hook( 

3303 data=self.data, 

3304 user_api_key_dict=user_api_key_dict, 

3305 response=upstream, 

3306 request_headers=request_headers, 

3307 ) 

3308 if callback_headers: 

3309 response_headers.update(callback_headers) 

3310 

3311 if is_event_stream: 

3312 body_bytes = await upstream.aread() 

3313 modified_bytes: Final = await self._handle_event_stream_allm_passthrough_route( 

3314 body_bytes=body_bytes, 

3315 proxy_logging_obj=proxy_logging_obj, 

3316 user_api_key_dict=user_api_key_dict, 

3317 ) 

3318 return Response( 

3319 content=modified_bytes, 

3320 status_code=response_status, 

3321 media_type=content_type, 

3322 headers=response_headers, 

3323 ) 

3324 

3325 body_bytes = await upstream.aread() 

3326 try: 

3327 parsed: Final = _json.loads(body_bytes) 

3328 except (_json.JSONDecodeError, UnicodeDecodeError): 

3329 return Response( 

3330 content=body_bytes, 

3331 status_code=response_status, 

3332 media_type="application/json", 

3333 headers=response_headers, 

3334 ) 

3335 processed: Final = await proxy_logging_obj.post_call_success_hook( 

3336 data=self.data, 

3337 user_api_key_dict=user_api_key_dict, 

3338 response=parsed, 

3339 ) 

3340 if isinstance(processed, dict): 

3341 content = _json.dumps(processed).encode() 

3342 else: 

3343 verbose_proxy_logger.debug( 

3344 "allm_passthrough_route: post_call_success_hook returned %s, leaving JSON response unmodified", 

3345 type(processed).__name__, 

3346 ) 

3347 content = body_bytes 

3348 return Response( 

3349 content=content, 

3350 status_code=response_status, 

3351 media_type="application/json", 

3352 headers=response_headers, 

3353 ) 

3354 

3355 async def _handle_event_stream_allm_passthrough_route( 

3356 self, 

3357 body_bytes: bytes, 

3358 proxy_logging_obj: "ProxyLogging", 

3359 user_api_key_dict: "UserAPIKeyAuth", 

3360 ) -> bytes: 

3361 from litellm.llms.pass_through.guardrail_translation.handler import ( 

3362 LlmPassthroughRouteHandler, 

3363 ) 

3364 

3365 return await LlmPassthroughRouteHandler.de_anonymize_event_stream( 

3366 body_bytes=body_bytes, 

3367 proxy_logging_obj=proxy_logging_obj, 

3368 user_api_key_dict=user_api_key_dict, 

3369 data=self.data, 

3370 ) 

3371 

3372 @staticmethod 

3373 def _flush_deferred_async_logging( 

3374 logging_obj: Any, 

3375 exception_raised: bool, 

3376 ) -> None: 

3377 """ 

3378 Fire the deferred async-success closure stored by wrapper_async, then 

3379 clear the slot. 

3380 

3381 Called from the finally block around post_call_success_hook so the 

3382 StandardLoggingPayload is built after post-call guardrails write to 

3383 metadata (deferred logging is enabled for non-streaming requests with 

3384 a registered post_call guardrail). 

3385 

3386 On exception (e.g. a post-call guardrail blocks the response), skip 

3387 firing the closure — the exception propagates to post_call_failure_hook 

3388 which writes its own failure spend log via async_failure_handler. 

3389 Firing both produced a duplicate (Success + Failure) entry per request, 

3390 with the Success row exposing the blocked LLM response. 

3391 

3392 For streaming early-returns the closure is never stored (wrapper_async 

3393 returns before the deferred block in litellm/utils.py), so this is a 

3394 no-op there. 

3395 

3396 Extracted as a static method so tests can exercise the production 

3397 gating logic directly rather than reimplementing the finally block. 

3398 """ 

3399 if getattr(logging_obj, "call_type", None) in ("ocr", "aocr"): 3399 ↛ 3400line 3399 didn't jump to line 3400 because the condition on line 3399 was never true

3400 pending: Final = getattr(logging_obj, "_native_pending_logging", None) 

3401 if pending is not None: 

3402 logging_obj._native_pending_logging = None # rebind-ok: consume the native OCR release signal once 

3403 pending.release(not exception_raised) 

3404 _enqueue_fn: Final = getattr(logging_obj, "_enqueue_deferred_logging", None) 

3405 if _enqueue_fn is None: 3405 ↛ 3407line 3405 didn't jump to line 3407 because the condition on line 3405 was always true

3406 return 

3407 logging_obj._enqueue_deferred_logging = None 

3408 if exception_raised: 

3409 return 

3410 try: 

3411 _enqueue_fn() 

3412 except Exception as e: 

3413 verbose_proxy_logger.exception("Error firing deferred logging: %s", e) 

3414 

3415 def _arm_deferred_stream_dispatch( 

3416 self, 

3417 response: object, 

3418 route_type: str, 

3419 user_api_key_dict: "UserAPIKeyAuth", 

3420 logging_obj: LiteLLMLoggingObj, 

3421 ) -> None: 

3422 """ 

3423 Streaming with post-call guardrails active: attach a closure that 

3424 ProxyLogging._fire_deferred_stream_logging fires after all guardrail 

3425 end-of-stream blocks complete, so the spend log sees 

3426 guardrail_information. 

3427 

3428 Two closure shapes, matching who owns logging for the stream: 

3429 - CustomStreamWrapper (chat completions) stores 

3430 (assembled_response, cache_hit); the closure also runs 

3431 non-apply_guardrail post-call hooks via 

3432 _run_deferred_stream_guardrails. 

3433 - Every other anthropic_messages/aresponses stream gets a closure 

3434 that dispatches on the stored args shape, because the arming site 

3435 cannot tell the producers apart: native iterators store a single 

3436 ready-made logging coroutine to enqueue, while bridged streams 

3437 (LiteLLMCompletionStreamingIterator, and the plain SSE generator 

3438 AnthropicStreamWrapper returns for bridged /v1/messages) share 

3439 their inner CustomStreamWrapper's logging_obj and so store 

3440 (assembled_response, cache_hit); for those the closure only 

3441 dispatches success logging, matching the route's pre-existing 

3442 hook surface. 

3443 

3444 Raw async generators from passthrough routes bypass both and would 

3445 orphan the closure, so they are not armed here. 

3446 

3447 The router wraps iterators that cannot carry _hidden_params in 

3448 HiddenParamsAsyncIteratorWrapper, so class sniffing runs on the 

3449 unwrapped inner iterator. 

3450 """ 

3451 from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper 

3452 from litellm.router_utils.add_retry_fallback_headers import HiddenParamsAsyncIteratorWrapper 

3453 

3454 unwrapped: Final = response._inner if isinstance(response, HiddenParamsAsyncIteratorWrapper) else response 

3455 

3456 if isinstance(unwrapped, CustomStreamWrapper): 

3457 # Intentionally a live reference (not a copy) — mirrors 

3458 # ProxyLogging.post_call_success_hook which also mutates 

3459 # data["guardrail_to_apply"] during iteration. 

3460 _captured_data: Final = self.data 

3461 _captured_user_api_key_dict: Final = user_api_key_dict 

3462 _captured_logging_obj: Final = logging_obj 

3463 

3464 async def _on_deferred_stream_complete(assembled_response: object, cache_hit: object) -> None: 

3465 await ProxyBaseLLMRequestProcessing._run_deferred_stream_guardrails( 

3466 captured_data=_captured_data, 

3467 captured_user_api_key_dict=_captured_user_api_key_dict, 

3468 captured_logging_obj=_captured_logging_obj, 

3469 assembled_response=assembled_response, 

3470 cache_hit=cache_hit, 

3471 ) 

3472 

3473 logging_obj._on_deferred_stream_complete = _on_deferred_stream_complete 

3474 return 

3475 

3476 if route_type not in ("anthropic_messages", "aresponses") or not self._is_streaming_response(response): 

3477 return 

3478 

3479 from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER 

3480 

3481 _captured_native_logging_obj: Final = logging_obj 

3482 

3483 async def _on_deferred_native_stream_complete(*args: object) -> None: 

3484 match args: 

3485 case (logging_coroutine,) if asyncio.iscoroutine(logging_coroutine): 

3486 GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine) 

3487 case (assembled_response, cache_hit): 

3488 await _as_success_dispatcher(_captured_native_logging_obj).dispatch_success_handlers( 

3489 assembled_response, 

3490 cache_hit=cache_hit, 

3491 start_time=None, 

3492 end_time=None, 

3493 prefer_async_handlers=True, 

3494 ) 

3495 case _: 

3496 verbose_proxy_logger.error( 

3497 "Deferred stream logging dropped: unexpected stored args shape %s", 

3498 tuple(type(arg).__name__ for arg in args), 

3499 ) 

3500 

3501 logging_obj._on_deferred_stream_complete = _on_deferred_native_stream_complete 

3502 

3503 @staticmethod 

3504 async def _run_deferred_stream_guardrails( 

3505 captured_data: dict, 

3506 captured_user_api_key_dict: "UserAPIKeyAuth", 

3507 captured_logging_obj: LiteLLMLoggingObj, 

3508 assembled_response: Any, 

3509 cache_hit: object, 

3510 ) -> None: 

3511 """ 

3512 Run non-streaming post-call guardrail hooks on an assembled streaming 

3513 response, then fire success logging via ``dispatch_success_handlers``. 

3514 

3515 Called by ProxyLogging._fire_deferred_stream_logging after the full 

3516 streaming pipeline (including unified_guardrail end-of-stream blocks) 

3517 has completed. 

3518 

3519 Guardrails routed through unified_guardrail are skipped, since they already ran 

3520 via its streaming iterator, and so are guardrails a post_call policy pipeline 

3521 manages, since the pipeline ran them against the buffered stream. Guardrails 

3522 that override async_post_call_success_hook directly run here, including those 

3523 that implement apply_guardrail but keep their native lifecycle hooks. 

3524 

3525 This is audit-only — content has already been delivered to the client. 

3526 

3527 Extracted as a static method so tests can call the production 

3528 implementation directly rather than reimplementing the closure. 

3529 """ 

3530 _response = assembled_response 

3531 try: 

3532 from litellm.proxy.proxy_server import llm_router as _global_llm_router 

3533 from litellm.proxy.utils import ( 

3534 _check_and_merge_model_level_guardrails, 

3535 stream_gated_guardrail_names, 

3536 ) 

3537 

3538 guardrail_data = _check_and_merge_model_level_guardrails(data=captured_data, llm_router=_global_llm_router) 

3539 stream_gated: Final = stream_gated_guardrail_names(captured_data, captured_user_api_key_dict) 

3540 for cb in litellm.callbacks: 

3541 if not isinstance(cb, CustomGuardrail): 

3542 continue 

3543 if cb.guardrail_name in stream_gated: 

3544 continue 

3545 if not cb.should_run_guardrail( 

3546 data=guardrail_data, 

3547 event_type=GuardrailEventHooks.post_call, 

3548 ): 

3549 continue 

3550 try: 

3551 guardrail_result = None 

3552 if "apply_guardrail" in type(cb).__dict__ and not cb.use_native_lifecycle_hooks: 

3553 # Skip — unified-routed guardrails already ran via 

3554 # unified_guardrail's end-of-stream block in the 

3555 # streaming iterator pipeline. Running them again 

3556 # here would duplicate the guardrail API call 

3557 # (e.g. double OpenAI Moderation charges). 

3558 continue 

3559 if "async_post_call_streaming_iterator_hook" in type(cb).__dict__: 

3560 # Skip — the guardrail already scanned the assembled 

3561 # response via its own streaming iterator hook in the 

3562 # streaming pipeline. re running this function async_post_call_success_hook 

3563 # here would duplicate the scan and can spuriously block the guardrail that already passed / failed. 

3564 continue 

3565 else: 

3566 guardrail_result = await cb.async_post_call_success_hook( 

3567 user_api_key_dict=captured_user_api_key_dict, 

3568 data=guardrail_data, 

3569 response=_response, 

3570 ) 

3571 if guardrail_result is not None: 

3572 _response = guardrail_result 

3573 except Exception as e: 

3574 verbose_proxy_logger.exception( 

3575 "Error running post-call guardrail %s on streaming response: %s", 

3576 getattr(cb, "guardrail_name", type(cb).__name__), 

3577 e, 

3578 ) 

3579 if isinstance(e, HTTPException) and hasattr(captured_logging_obj, "model_call_details"): 

3580 captured_logging_obj.model_call_details.setdefault("metadata", {})["guardrail_blocked"] = True 

3581 except Exception as e: 

3582 verbose_proxy_logger.exception( 

3583 "Error in deferred streaming guardrail initialization: %s", 

3584 e, 

3585 ) 

3586 finally: 

3587 try: 

3588 # Proxy streaming always runs in async context and proxy spend 

3589 # logging is async-only; force async dispatch so DB/spend 

3590 # callbacks fire regardless of the call-type heuristic in 

3591 # _is_sync_litellm_request (which only recognizes a subset of 

3592 # async markers stored in litellm_params). 

3593 asyncio.create_task( 

3594 _as_success_dispatcher(captured_logging_obj).dispatch_success_handlers( 

3595 _response, 

3596 cache_hit=cache_hit, 

3597 start_time=None, 

3598 end_time=None, 

3599 prefer_async_handlers=True, 

3600 ) 

3601 ) 

3602 except Exception as e: 

3603 verbose_proxy_logger.exception( 

3604 "Error in deferred streaming success logging: %s", 

3605 e, 

3606 ) 

3607 

3608 def _apply_router_cooldown_retry_after(self, headers: dict, e: Exception) -> None: 

3609 if isinstance(e, RouterRateLimitError) and e.cooldown_time > 0: 3609 ↛ 3610line 3609 didn't jump to line 3610 because the condition on line 3609 was never true

3610 headers["retry-after"] = str(math.ceil(e.cooldown_time)) 

3611 

3612 async def _handle_llm_api_exception( 

3613 self, 

3614 e: Exception, 

3615 user_api_key_dict: UserAPIKeyAuth, 

3616 proxy_logging_obj: ProxyLogging, 

3617 version: str | None = None, 

3618 ): 

3619 """Raises ProxyException (OpenAI API compatible) if an exception is raised""" 

3620 log_llm_api_exception(e, self.litellm_call_id) 

3621 # Allow callbacks to transform the error response 

3622 transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook( 

3623 user_api_key_dict=user_api_key_dict, 

3624 original_exception=e, 

3625 request_data=self.data, 

3626 ) 

3627 # Use transformed exception if callback returned one, otherwise use original 

3628 if transformed_exception is not None: 3628 ↛ 3629line 3628 didn't jump to line 3629 because the condition on line 3628 was never true

3629 e = transformed_exception 

3630 litellm_debug_info: Final = getattr(e, "litellm_debug_info", "") 

3631 verbose_proxy_logger.debug( 

3632 "\033[1;31mAn error occurred: %s %s\n\n Debug this by setting `--debug`, e.g. `litellm --model gpt-3.5-turbo --debug`", 

3633 e, 

3634 litellm_debug_info, 

3635 ) 

3636 

3637 timeout: Final = getattr( 

3638 e, "timeout", None 

3639 ) # returns the timeout set by the wrapper. Used for testing if model-specific timeout are set correctly 

3640 _litellm_logging_obj: Final[LiteLLMLoggingObj | None] = self.data.get("litellm_logging_obj", None) 

3641 

3642 # Attempt to get model_id from logging object 

3643 # 

3644 # Note: We check the direct model_info path first (not nested in metadata) because that's where the router sets it. 

3645 # The nested metadata path is only a fallback for cases where model_info wasn't set at the top level. 

3646 model_id: Final = self.maybe_get_model_id(_litellm_logging_obj) 

3647 

3648 custom_headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers( 

3649 user_api_key_dict=user_api_key_dict, 

3650 call_id=self.litellm_call_id, 

3651 model_id=model_id, 

3652 version=version, 

3653 response_cost=0, 

3654 model_region=getattr(user_api_key_dict, "allowed_model_region", ""), 

3655 request_data=self.data, 

3656 timeout=timeout, 

3657 litellm_logging_obj=_litellm_logging_obj, 

3658 # a failed request reports no timing, matching /v1/chat/completions 

3659 read_timing_from_logging_obj=False, 

3660 ) 

3661 headers = getattr(e, "headers", None) or {} 

3662 if not headers: 3662 ↛ 3668line 3662 didn't jump to line 3668 because the condition on line 3662 was always true

3663 _response: Final = attribute_of(e, "response") 

3664 _response_headers: Final = getattr(_response, "headers", None) if _response is not None else None 

3665 _provider_headers: Final = _response_headers or getattr(e, "litellm_response_headers", None) 

3666 if _provider_headers: 

3667 headers = get_response_headers(dict(_provider_headers)) 

3668 headers.update(custom_headers) 

3669 

3670 # Call response headers hook for failure 

3671 try: 

3672 callback_headers: Final = await proxy_logging_obj.post_call_response_headers_hook( 

3673 data=self.data, 

3674 user_api_key_dict=user_api_key_dict, 

3675 response=None, 

3676 request_headers=(self.data.get("proxy_server_request") or {}).get("headers", {}), 

3677 ) 

3678 if callback_headers: 3678 ↛ 3679line 3678 didn't jump to line 3679 because the condition on line 3678 was never true

3679 headers.update(callback_headers) 

3680 except Exception: 

3681 pass 

3682 

3683 safe_headers: Final = {k: v for k, v in headers.items() if k.lower() not in UNSAFE_PROXY_RESPONSE_HEADERS} 

3684 

3685 self._apply_router_cooldown_retry_after(safe_headers, e) 

3686 

3687 if isinstance(e, ProxyException): 

3688 e.headers = { 

3689 **{k: v for k, v in e.headers.items() if k.lower() not in UNSAFE_PROXY_RESPONSE_HEADERS}, 

3690 **{k: v if isinstance(v, str) else str(v) for k, v in safe_headers.items()}, 

3691 } 

3692 raise e 

3693 

3694 if isinstance(e, HTTPException): 

3695 raise proxy_exception_from_http_exception(e, safe_headers) 

3696 elif isinstance(e, httpx.HTTPStatusError): 3696 ↛ 3699line 3696 didn't jump to line 3699 because the condition on line 3696 was never true

3697 # Handle httpx.HTTPStatusError - extract actual error from response 

3698 # This matches the original behavior before the refactor in commit 511d435f6f 

3699 http_status_error: Final[httpx.HTTPStatusError] = e 

3700 error_body: Final = await http_status_error.response.aread() 

3701 error_text: Final = error_body.decode("utf-8") 

3702 

3703 error_headers: Final = { # mutable-ok: HTTPException takes a plain header dict 

3704 k: v if isinstance(v, str) else str(v) for k, v in safe_headers.items() 

3705 } 

3706 raise HTTPException( 

3707 status_code=http_status_error.response.status_code, 

3708 detail={"error": error_text}, 

3709 headers=error_headers, 

3710 ) 

3711 error_msg: Final = f"{e}" 

3712 # Check for AttributeError in the exception chain. 

3713 # The AttributeError may be wrapped in multiple layers 

3714 # (e.g. AttributeError -> OpenAIException -> APIConnectionError), 

3715 # so walk __cause__, __context__, and original_exception recursively. 

3716 has_attribute_error: Final = _has_attribute_error_in_chain(e) 

3717 

3718 if has_attribute_error: 

3719 raise ProxyException( 

3720 message=f"Invalid request format: {error_msg}", 

3721 type="invalid_request_error", 

3722 param=None, 

3723 code=status.HTTP_400_BAD_REQUEST, 

3724 headers=safe_headers, 

3725 ) 

3726 # Extract status_code from the exception if it carries one. 

3727 # Provider exceptions (NotFoundError, BadRequestError, GeminiError, 

3728 # VertexAIError, etc.) all have a status_code attribute reflecting 

3729 # the upstream API response. Use it to return the correct HTTP code 

3730 # instead of defaulting to 500. 

3731 _exc_status_code: Final = getattr(e, "status_code", None) 

3732 if _exc_status_code is not None and isinstance(_exc_status_code, int) and 400 <= _exc_status_code <= 599: 

3733 _code = _exc_status_code 

3734 else: 

3735 _code = status.HTTP_500_INTERNAL_SERVER_ERROR 

3736 if should_report_bug(e): 3736 ↛ 3752line 3736 didn't jump to line 3752 because the condition on line 3736 was always true

3737 proxy_server_request: Final = self.data.get("proxy_server_request") 

3738 request_url: Final = ( 

3739 proxy_server_request.get("url") if isinstance(proxy_server_request, Mapping) else None 

3740 ) 

3741 request_path: Final = urlparse(str(request_url)).path if request_url is not None else None 

3742 verbose_proxy_logger.error( 

3743 bug_report_notice( 

3744 build_proxy_bug_report( 

3745 e, 

3746 call_type=allowlisted(request_path, KNOWN_PROXY_ROUTES), 

3747 custom_llm_provider=self.data.get("custom_llm_provider"), 

3748 stream=self.data.get("stream"), 

3749 ) 

3750 ) 

3751 ) 

3752 client_message: Final = getattr(e, "message", error_msg) 

3753 raise ProxyException( 

3754 message=redact_internal_details_from_client_message( 

3755 strip_bug_report_notice(client_message) if isinstance(client_message, str) else error_msg 

3756 ), 

3757 type=openai_error_type(e, _code), 

3758 param=openai_error_param(e), 

3759 openai_code=getattr(e, "code", None), 

3760 code=_code, 

3761 provider_specific_fields=getattr(e, "provider_specific_fields", None), 

3762 headers=safe_headers, 

3763 ) 

3764 

3765 ######################################################### 

3766 # Proxy Level Streaming Data Generator 

3767 ######################################################### 

3768 

3769 @staticmethod 

3770 def return_sse_chunk(chunk: Any) -> str: 

3771 """ 

3772 Helper function to format streaming chunks for Anthropic API format 

3773 

3774 Args: 

3775 chunk: A string or dictionary to be returned in SSE format 

3776 

3777 Returns: 

3778 str: A properly formatted SSE chunk string 

3779 """ 

3780 if isinstance(chunk, dict): 

3781 # Use safe_dumps for proper JSON serialization with circular reference detection 

3782 chunk_str: Final = safe_dumps(chunk) 

3783 return f"{STREAM_SSE_DATA_PREFIX}{chunk_str}\n\n" 

3784 else: 

3785 return chunk 

3786 

3787 @staticmethod 

3788 def _sse_chunk_serializer(restamper: AnthropicStreamModelRestamper | None) -> StreamChunkSerializer: 

3789 if restamper is None: 

3790 return ProxyBaseLLMRequestProcessing.return_sse_chunk 

3791 

3792 def serialize(chunk: object) -> str: 

3793 return ProxyBaseLLMRequestProcessing.return_sse_chunk(restamper.process(chunk)) 

3794 

3795 return serialize 

3796 

3797 @staticmethod 

3798 async def _finalize_streaming_generator_cleanup( 

3799 request: Request | None, 

3800 request_data: dict, 

3801 response: Any, 

3802 stream_completed: bool = False, 

3803 client_disconnected: bool = False, 

3804 user_api_key_dict: UserAPIKeyAuth | None = None, 

3805 proxy_logging_obj: ProxyLogging | None = None, 

3806 ) -> None: 

3807 with anyio.CancelScope(shield=True): 

3808 should_record_client_disconnect: Final = client_disconnected or (not stream_completed) 

3809 recorded_client_disconnect = False 

3810 if should_record_client_disconnect: 

3811 recorded_client_disconnect = await _record_streaming_client_disconnect_if_needed( 

3812 request, 

3813 request_data, 

3814 client_disconnected, 

3815 ) 

3816 if recorded_client_disconnect: 

3817 deferred_stream_logging_armed: Final = _deferred_stream_logging_is_armed(request_data) 

3818 ProxyLogging._fire_deferred_stream_logging(request_data) 

3819 # A disconnect-time success event (the deferred-guardrail flush 

3820 # above, or the partial-spend billing below) releases the 

3821 # request's max_parallel_requests slot through the limiter's 

3822 # own success callback. Release the slot explicitly only when 

3823 # no such event fires, so exactly one release happens; two 

3824 # concurrent releases would race and double-decrement under the 

3825 # limiter's in-memory fallback. 

3826 success_event_owns_slot_release = deferred_stream_logging_armed 

3827 if not deferred_stream_logging_armed: 

3828 success_event_owns_slot_release = await _bill_partial_streamed_spend_on_disconnect( 

3829 request_data, response 

3830 ) 

3831 if ( 

3832 not success_event_owns_slot_release 

3833 and proxy_logging_obj is not None 

3834 and user_api_key_dict is not None 

3835 ): 

3836 await proxy_logging_obj._arelease_max_parallel_requests_on_disconnect(user_api_key_dict) 

3837 

3838 if hasattr(response, "aclose"): 

3839 try: 

3840 await response.aclose() 

3841 except BaseException as e: # noqa: BLE001 

3842 verbose_proxy_logger.debug( 

3843 "async_streaming_data_generator: error closing response stream: %s", 

3844 e, 

3845 ) 

3846 logging_obj: Final = request_data.get("litellm_logging_obj") 

3847 if ( 

3848 not stream_completed 

3849 and isinstance(logging_obj, LiteLLMLoggingObj) 

3850 and logging_obj.baseline_cache_context is not None 

3851 and logging_obj.model_call_details.get("prompt_cache_response_complete") is not True 

3852 ): 

3853 await logging_obj.invalidate_baseline_cache_estimate("incomplete_response", completed=True) 

3854 

3855 @staticmethod 

3856 async def async_streaming_data_generator( 

3857 response: object, 

3858 user_api_key_dict: UserAPIKeyAuth, 

3859 request_data: dict, 

3860 proxy_logging_obj: ProxyLogging, 

3861 *, 

3862 serialize_chunk: StreamChunkSerializer, 

3863 serialize_error: StreamErrorSerializer, 

3864 request: Request | None = None, 

3865 flush_tail: Callable[[], bytes] | None = None, 

3866 ) -> AsyncGenerator[str, None]: 

3867 """ 

3868 Shared streaming data generator: runs proxy iterator hook, per-chunk hook, 

3869 cost injection, then yields chunks via serialize_chunk; on exception runs 

3870 failure hook and yields via serialize_error. Use for SSE or NDJSON. 

3871 

3872 ``flush_tail`` runs once after the upstream iterator completes cleanly and 

3873 its non-empty result is yielded, so a serializer that buffers bytes across 

3874 chunks can emit anything still held at end of stream. 

3875 """ 

3876 verbose_proxy_logger.debug("inside generator") 

3877 # Resolve per-stream (not per-chunk) whether the heavy per-chunk path 

3878 # is needed. When no callback overrides ``async_post_call_streaming_hook``, 

3879 # no CustomGuardrail is active, and cost injection is disabled, the 

3880 # per-chunk hook returns the chunk unchanged, ``str_so_far`` is never 

3881 # consumed, and cost injection is a no-op -- so the per-chunk coroutine 

3882 # await, response-string materialization, and cost-injection call are 

3883 # pure overhead on the streaming hot path (the default config). 

3884 caps: Final = ProxyLogging._callback_capabilities() 

3885 cost_injection_enabled: Final = bool(getattr(litellm, "include_cost_in_streaming_usage", False)) 

3886 fast_path = not caps.has_streaming_chunk_override and not caps.has_guardrail and not cost_injection_enabled 

3887 debug_enabled: Final = verbose_proxy_logger.isEnabledFor(logging.DEBUG) 

3888 stream_completed = False 

3889 client_disconnected = False 

3890 delivered_chunk = False 

3891 try: 

3892 str_so_far = "" 

3893 async for chunk in proxy_logging_obj.async_post_call_streaming_iterator_hook( 

3894 user_api_key_dict=user_api_key_dict, 

3895 response=response, 

3896 request_data=request_data, 

3897 ): 

3898 # ``.format(chunk)`` was previously evaluated for every chunk 

3899 # regardless of log level; gate it behind the level check. 

3900 if debug_enabled: 

3901 verbose_proxy_logger.debug("async_data_generator: received streaming chunk - %s", chunk) 

3902 

3903 if not fast_path: 

3904 chunk = await proxy_logging_obj.async_post_call_streaming_hook( 

3905 user_api_key_dict=user_api_key_dict, 

3906 response=chunk, 

3907 data=request_data, 

3908 str_so_far=str_so_far, 

3909 ) 

3910 

3911 if isinstance(chunk, (ModelResponse, ModelResponseStream)): 

3912 response_str = litellm.get_response_string(response_obj=chunk) 

3913 str_so_far += response_str 

3914 elif hasattr(chunk, "model_dump"): 

3915 try: 

3916 d = chunk.model_dump(mode="json", exclude_none=True) 

3917 if isinstance(d, dict): 

3918 str_so_far += str(d.get("content", "")) 

3919 except Exception: 

3920 pass 

3921 elif isinstance(chunk, dict): 

3922 str_so_far += str(chunk.get("content", "")) 

3923 

3924 model_name = request_data.get("model", "") 

3925 chunk = ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection( 

3926 chunk, model_name, request_data.get("litellm_logging_obj") 

3927 ) 

3928 

3929 # Set before the yield: an async generator suspends at the yield, 

3930 # so a GeneratorExit on client disconnect is raised there and any 

3931 # statement after the yield never runs. The slow-path hook is 

3932 # awaited above, so a cancellation during it still leaves this 

3933 # False and refunds. A keepalive ping carries no provider output, 

3934 # so it must not suppress that refund. 

3935 delivered_chunk = delivered_chunk or chunk != STREAM_SSE_KEEPALIVE_PING_BYTES 

3936 yield serialize_chunk(chunk) 

3937 held_tail: Final = flush_tail() if flush_tail is not None else b"" 

3938 if held_tail: 

3939 yield serialize_chunk(held_tail) 

3940 stream_completed = True 

3941 except (asyncio.CancelledError, GeneratorExit): 

3942 # Client disconnected mid-stream. CancelledError / GeneratorExit 

3943 # are BaseException and bypass the success/failure logging 

3944 # callbacks that release the pre-call max_parallel_requests +1. 

3945 # Flag the disconnect; the shielded cleanup in `finally` owns the 

3946 # slot release so it can coordinate with disconnect-time success 

3947 # billing and release exactly once. This is the outermost generator 

3948 # Starlette closes on disconnect, so the nested iterator hook (which 

3949 # only sees GeneratorExit on GC) cannot own the refund. 

3950 client_disconnected = not stream_completed 

3951 if not delivered_chunk and not _withheld_provider_output(response): 

3952 from litellm.proxy.spend_tracking.budget_reservation import ( 

3953 release_budget_reservation_on_cancel, 

3954 ) 

3955 

3956 await release_budget_reservation_on_cancel(getattr(user_api_key_dict, "budget_reservation", None)) 

3957 raise 

3958 except Exception as e: 

3959 verbose_proxy_logger.exception( 

3960 "litellm.proxy.proxy_server.async_data_generator(): Exception occured - %s", e 

3961 ) 

3962 transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook( 

3963 user_api_key_dict=user_api_key_dict, 

3964 original_exception=e, 

3965 request_data=request_data, 

3966 ) 

3967 if transformed_exception is not None: 

3968 e = transformed_exception 

3969 verbose_proxy_logger.debug( 

3970 "\x1b[1;31mAn error occurred: %s\n\n Debug this by setting `--debug`, e.g. `litellm --model gpt-3.5-turbo --debug`", 

3971 e, 

3972 ) 

3973 

3974 if isinstance(e, HTTPException): 

3975 raise e 

3976 stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR) 

3977 proxy_exception: Final = ProxyException( 

3978 message=redact_internal_details_from_client_message(getattr(e, "message", str(e))), 

3979 type=openai_error_type(e, stream_error_status), 

3980 param=openai_error_param(e), 

3981 code=stream_error_status, 

3982 ) 

3983 stream_completed = True 

3984 yield serialize_error(proxy_exception) 

3985 finally: 

3986 await ProxyBaseLLMRequestProcessing._finalize_streaming_generator_cleanup( 

3987 request=request, 

3988 request_data=request_data, 

3989 response=response, 

3990 stream_completed=stream_completed, 

3991 client_disconnected=client_disconnected, 

3992 user_api_key_dict=user_api_key_dict, 

3993 proxy_logging_obj=proxy_logging_obj, 

3994 ) 

3995 

3996 @staticmethod 

3997 def async_sse_data_generator( 

3998 response: object, 

3999 user_api_key_dict: UserAPIKeyAuth, 

4000 request_data: dict, 

4001 proxy_logging_obj: ProxyLogging, 

4002 request: Request | None = None, 

4003 restamp_model: str | None = None, 

4004 ) -> AsyncGenerator[str, None]: 

4005 """ 

4006 Anthropic /messages and Google /generateContent streaming data generator require SSE events. 

4007 

4008 Returns the underlying ``async_streaming_data_generator`` configured with 

4009 SSE serializers directly (rather than re-wrapping it in another 

4010 ``async for: yield`` trampoline), so a streamed chunk traverses one 

4011 fewer async-generator layer / coroutine resume on the hot path. 

4012 

4013 ``restamp_model`` publishes that name on the Anthropic ``message_start`` 

4014 event in place of the provider's model, matching what the non-streaming 

4015 response reports. 

4016 """ 

4017 restamper: Final = AnthropicStreamModelRestamper(restamp_model) if restamp_model else None 

4018 return ProxyBaseLLMRequestProcessing.async_streaming_data_generator( 

4019 response=response, 

4020 user_api_key_dict=user_api_key_dict, 

4021 request_data=request_data, 

4022 proxy_logging_obj=proxy_logging_obj, 

4023 serialize_chunk=ProxyBaseLLMRequestProcessing._sse_chunk_serializer(restamper), 

4024 serialize_error=lambda proxy_exc: ( 

4025 f"{STREAM_SSE_DATA_PREFIX}{json.dumps({'error': proxy_exc.to_dict()})}\n\n" 

4026 ), 

4027 request=request, 

4028 flush_tail=None if restamper is None else restamper.flush, 

4029 ) 

4030 

4031 @overload 

4032 @staticmethod 

4033 def _process_chunk_with_cost_injection( 4033 ↛ exitline 4033 didn't return from function '_process_chunk_with_cost_injection' because

4034 chunk: bytes, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None 

4035 ) -> bytes: ... 

4036 

4037 @overload 

4038 @staticmethod 

4039 def _process_chunk_with_cost_injection( 4039 ↛ exitline 4039 didn't return from function '_process_chunk_with_cost_injection' because

4040 chunk: object, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None 

4041 ) -> object: ... 

4042 

4043 @staticmethod 

4044 def _process_chunk_with_cost_injection( 

4045 chunk: object, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None 

4046 ) -> object: 

4047 """ 

4048 Process a streaming chunk and inject cost information if enabled. 

4049 

4050 Args: 

4051 chunk: The streaming chunk (dict, str, bytes, or bytearray) 

4052 model_name: Model name for cost calculation 

4053 litellm_logging_obj: The call's logging object, used for pricing 

4054 

4055 Returns: 

4056 The processed chunk with cost information injected if applicable 

4057 """ 

4058 if not getattr(litellm, "include_cost_in_streaming_usage", False): 

4059 return chunk 

4060 

4061 try: 

4062 if isinstance(chunk, dict): 

4063 maybe_modified: Final = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict( 

4064 chunk, model_name, litellm_logging_obj 

4065 ) 

4066 if maybe_modified is not None: 

4067 return maybe_modified 

4068 elif isinstance(chunk, (bytes, bytearray)): 

4069 try: 

4070 s: Final = chunk.decode("utf-8") 

4071 if s.endswith(("\n\n", "\r\n\r\n")): 

4072 maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str( 

4073 s, model_name, litellm_logging_obj 

4074 ) 

4075 if maybe_mod is not None: 

4076 return maybe_mod.encode("utf-8") 

4077 except Exception: 

4078 pass 

4079 elif isinstance(chunk, str): 

4080 # Try to parse SSE frame and inject cost into the data line 

4081 maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str( 

4082 chunk, model_name, litellm_logging_obj 

4083 ) 

4084 if maybe_mod is not None: 

4085 # Ensure trailing frame separator 

4086 return maybe_mod if maybe_mod.endswith("\n\n") else (maybe_mod + "\n\n") 

4087 except Exception: 

4088 # Never break streaming on optional cost injection 

4089 pass 

4090 

4091 return chunk 

4092 

4093 @staticmethod 

4094 def _inject_cost_into_sse_frame_str( 

4095 frame_str: str, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None 

4096 ) -> str | None: 

4097 """ 

4098 Inject cost information into an SSE frame string by modifying the JSON in the 'data:' line. 

4099 

4100 Args: 

4101 frame_str: SSE frame string that may contain multiple lines 

4102 model_name: Model name for cost calculation 

4103 litellm_logging_obj: The call's logging object, forwarded for pricing 

4104 

4105 Returns: 

4106 Modified SSE frame string with cost injected, or None if no modification needed 

4107 """ 

4108 try: 

4109 # Split preserving lines 

4110 lines: Final = frame_str.split("\n") 

4111 for idx, ln in enumerate(lines): 

4112 stripped_ln = ln.strip() 

4113 if stripped_ln.startswith("data:"): 

4114 json_part = stripped_ln.split("data:", 1)[1].strip() 

4115 if json_part and json_part != "[DONE]": 

4116 obj = json.loads(json_part) 

4117 maybe_modified = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict( 

4118 obj, model_name, litellm_logging_obj 

4119 ) 

4120 if maybe_modified is not None: 

4121 lines[idx] = "data: " + safe_dumps(maybe_modified) + ("\r" if ln.endswith("\r") else "") 

4122 return "\n".join(lines) 

4123 return None 

4124 except Exception: 

4125 return None 

4126 

4127 @staticmethod 

4128 def _openai_stream_usage_kwargs(usage: Mapping[str, Any]) -> Mapping[str, Any]: 

4129 prompt_tokens: Final = int(usage.get("prompt_tokens", 0) or 0) 

4130 completion_tokens: Final = int(usage.get("completion_tokens", 0) or 0) 

4131 total_tokens: Final = int( 

4132 usage.get("total_tokens", prompt_tokens + completion_tokens) or (prompt_tokens + completion_tokens) 

4133 ) 

4134 return MappingProxyType( 

4135 { 

4136 key: value 

4137 for key, value in ( 

4138 ("prompt_tokens", prompt_tokens), 

4139 ("completion_tokens", completion_tokens), 

4140 ("total_tokens", total_tokens), 

4141 ("completion_tokens_details", usage.get("completion_tokens_details")), 

4142 ("prompt_tokens_details", usage.get("prompt_tokens_details")), 

4143 ) 

4144 if value is not None 

4145 } 

4146 ) 

4147 

4148 @staticmethod 

4149 def _stream_usage_for_event(obj: Mapping[str, object], usage: Mapping[str, Any]) -> Usage | None: 

4150 # Anthropic reports input_tokens excluding cache tokens, so reuse the non-streaming 

4151 # transformation to total the prompt and keep the 5m/1h cache creation split 

4152 if obj.get("type") == "message_delta": 

4153 return AnthropicConfig().calculate_usage(usage_object=usage, reasoning_content=None) 

4154 if obj.get("object") == "chat.completion.chunk": 

4155 return Usage(**ProxyBaseLLMRequestProcessing._openai_stream_usage_kwargs(usage)) 

4156 return None 

4157 

4158 @staticmethod 

4159 def _completion_cost_or_none( 

4160 model_response: ModelResponse, model_name: str, service_tier: str | None 

4161 ) -> float | None: 

4162 try: 

4163 return litellm.completion_cost( 

4164 completion_response=model_response, model=model_name, service_tier=service_tier 

4165 ) 

4166 except Exception: 

4167 return None 

4168 

4169 @staticmethod 

4170 def _logging_obj_cost_or_none( 

4171 model_response: ModelResponse, litellm_logging_obj: LiteLLMLoggingObj 

4172 ) -> float | None: 

4173 # Pricing a frame stamps cost_breakdown and, on failure, the cost-failure debug key onto 

4174 # the live logging object. The pass-through handlers never recompute either one, so a 

4175 # frame-derived breakdown would outlive the stream and land in the spend log. Snapshot 

4176 # both and put them back, so pricing here stays a read as far as the request is concerned 

4177 breakdown_before: Final = getattr(litellm_logging_obj, "cost_breakdown", None) 

4178 call_details: Final = getattr(litellm_logging_obj, "model_call_details", None) 

4179 debug_key: Final = "response_cost_failure_debug_information" 

4180 debug_missing: Final = object() 

4181 debug_before: Final = call_details.get(debug_key, debug_missing) if isinstance(call_details, dict) else None 

4182 try: 

4183 cost: Final = litellm_logging_obj._response_cost_calculator(result=model_response) # pyright: ignore[reportPrivateUsage] # reuse the call's own cost calc for pricing parity with the logging callback 

4184 except Exception: # noqa: BLE001 # a pricing failure falls back to model-name pricing instead of breaking the stream 

4185 return None 

4186 finally: 

4187 if hasattr(litellm_logging_obj, "cost_breakdown"): 

4188 litellm_logging_obj.cost_breakdown = breakdown_before 

4189 if isinstance(call_details, dict): 

4190 if debug_before is debug_missing: 

4191 call_details.pop(debug_key, None) 

4192 else: 

4193 call_details[debug_key] = debug_before 

4194 return float(cost) if isinstance(cost, (int, float)) and not isinstance(cost, bool) else None 

4195 

4196 @staticmethod 

4197 def _streamed_usage_cost( 

4198 model_response: ModelResponse, 

4199 model_name: str, 

4200 service_tier: str | None, 

4201 litellm_logging_obj: LiteLLMLoggingObj | None, 

4202 ) -> float | None: 

4203 # Pricing via the logging object inherits the deployment's custom pricing, so the 

4204 # streamed cost matches what the logging callback records instead of sticker price 

4205 cost_from_logging_obj: Final = ( 

4206 ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, litellm_logging_obj) 

4207 if litellm_logging_obj is not None 

4208 else None 

4209 ) 

4210 if cost_from_logging_obj is not None: 

4211 return cost_from_logging_obj 

4212 return ProxyBaseLLMRequestProcessing._completion_cost_or_none(model_response, model_name, service_tier) 

4213 

4214 @staticmethod 

4215 def _inject_cost_into_usage_dict( 

4216 obj: dict, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None 

4217 ) -> dict | None: 

4218 """ 

4219 Inject cost information into the usage object of a streamed usage event 

4220 (Anthropic ``message_delta`` or OpenAI ``chat.completion.chunk``). 

4221 

4222 Args: 

4223 obj: Dictionary containing the SSE event data 

4224 model_name: Model name for cost calculation 

4225 litellm_logging_obj: The call's logging object, used for pricing 

4226 

4227 Returns: 

4228 Modified dictionary with cost injected, or None if no modification needed 

4229 """ 

4230 usage: Final = obj.get("usage") 

4231 if not isinstance(usage, dict): 

4232 return None 

4233 stream_usage: Final = ProxyBaseLLMRequestProcessing._stream_usage_for_event(obj, usage) 

4234 if stream_usage is None: 

4235 return None 

4236 service_tier: Final = obj.get("service_tier") 

4237 cost_val: Final = ProxyBaseLLMRequestProcessing._streamed_usage_cost( 

4238 ModelResponse(usage=stream_usage), 

4239 model_name, 

4240 service_tier if isinstance(service_tier, str) else None, 

4241 litellm_logging_obj, 

4242 ) 

4243 if cost_val is None: 

4244 return None 

4245 return {**obj, "usage": {**usage, "cost": cost_val}} 

4246 

4247 def maybe_get_model_id(self, _logging_obj: LiteLLMLoggingObj | None) -> str | None: 

4248 """ 

4249 Get model_id from logging object or request metadata. 

4250 

4251 The router sets model_info.id when selecting a deployment. This tries multiple locations 

4252 where the ID might be stored depending on the request lifecycle stage. 

4253 """ 

4254 model_id = None 

4255 if _logging_obj: 4255 ↛ 4257line 4255 didn't jump to line 4257 because the condition on line 4255 was never true

4256 # 1. Try getting from litellm_params (updated during call) 

4257 if hasattr(_logging_obj, "litellm_params") and _logging_obj.litellm_params: 

4258 # First check direct model_info path (set by router.py with selected deployment) 

4259 model_info = _logging_obj.litellm_params.get("model_info") or {} 

4260 model_id = model_info.get("id", None) 

4261 

4262 # Fallback to nested metadata path 

4263 if not model_id: 

4264 metadata = _logging_obj.litellm_params.get("metadata") or {} 

4265 model_info = metadata.get("model_info") or {} 

4266 model_id = model_info.get("id", None) 

4267 

4268 # 2. Fallback to kwargs (initial) 

4269 if not model_id: 

4270 _kwargs: Final = getattr(_logging_obj, "kwargs", None) 

4271 if _kwargs: 

4272 litellm_params: Final = _kwargs.get("litellm_params", {}) 

4273 # First check direct model_info path 

4274 model_info = litellm_params.get("model_info") or {} 

4275 model_id = model_info.get("id", None) 

4276 

4277 # Fallback to nested metadata path 

4278 if not model_id: 

4279 metadata = litellm_params.get("metadata") or {} 

4280 model_info = metadata.get("model_info") or {} 

4281 model_id = model_info.get("id", None) 

4282 

4283 # 3. Final fallback to self.data["litellm_metadata"] (for routes like /v1/responses that populate data before error) 

4284 if not model_id: 4284 ↛ 4289line 4284 didn't jump to line 4289 because the condition on line 4284 was always true

4285 litellm_metadata: Final = self.data.get("litellm_metadata", {}) or {} 

4286 model_info = litellm_metadata.get("model_info", {}) or {} 

4287 model_id = model_info.get("id", None) 

4288 

4289 return model_id