-
-
Notifications
You must be signed in to change notification settings - Fork 11.2k
fix(proxy): invoke post-call guardrails on pass-through endpoint responses (#20270) #26262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -227,6 +227,16 @@ async def async_post_call_success_hook( | |
| if call_type is None: | ||
| call_type = _infer_call_type(call_type=None, completion_response=response) # type: ignore | ||
|
|
||
| # Fallback: resolve call_type from logging_obj for pass-through endpoints | ||
| if call_type is None: | ||
| litellm_logging_obj = data.get("litellm_logging_obj") | ||
| if ( | ||
| litellm_logging_obj is not None | ||
| and getattr(litellm_logging_obj, "call_type", None) | ||
| == CallTypes.pass_through.value | ||
| ): | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a pre-existing log line in |
||
| call_type = CallTypes.pass_through.value | ||
|
|
||
| if call_type is None: | ||
| return response | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -635,6 +635,7 @@ async def pass_through_request( # noqa: PLR0915 | |
| custom_llm_provider: Optional field - custom LLM provider for the endpoint | ||
| guardrails_config: Optional field - guardrails configuration for passthrough endpoint | ||
| """ | ||
| from litellm.exceptions import ModifyResponseException | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above — this is the same pre-existing |
||
| from litellm.litellm_core_utils.litellm_logging import Logging | ||
| from litellm.proxy.pass_through_endpoints.passthrough_guardrails import ( | ||
| PassthroughGuardrailHandler, | ||
|
|
@@ -915,8 +916,41 @@ async def pass_through_request( # noqa: PLR0915 | |
|
|
||
| content = await response.aread() | ||
|
|
||
| ## LOG SUCCESS | ||
| ## POST-CALL GUARDRAILS ## | ||
| _content_modified = False | ||
| response_body: Optional[dict] = get_response_body(response) | ||
| if response_body is not None and guardrails_to_run: | ||
| # Build an enriched data dict: _parsed_body has been stripped of | ||
| # `metadata` by both pre_call_hook and _init_kwargs_for_pass_through_endpoint, | ||
| # so we re-attach the configured guardrails here so should_run_guardrail | ||
| # sees them. | ||
| hook_data = dict(_parsed_body or {}) | ||
| existing_metadata = hook_data.get("metadata") | ||
| if not isinstance(existing_metadata, dict): | ||
| existing_metadata = {} | ||
| hook_data["metadata"] = { | ||
| **existing_metadata, | ||
| "guardrails": guardrails_to_run, | ||
| } | ||
| response_body = await proxy_logging_obj.post_call_success_hook( | ||
| data=hook_data, | ||
| user_api_key_dict=user_api_key_dict, | ||
| response=response_body, # type: ignore[arg-type] | ||
| ) | ||
|
Comment on lines
+922
to
+939
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — this is consistent with how |
||
| if isinstance(response_body, dict): | ||
| content = json.dumps(response_body).encode("utf-8") | ||
|
tuhinspatra marked this conversation as resolved.
|
||
| _content_modified = True | ||
| else: | ||
| verbose_proxy_logger.debug( | ||
| "pass_through_endpoint: post_call_success_hook returned %s, expected dict — using original response", | ||
| type(response_body).__name__, | ||
| ) | ||
| elif response_body is None: | ||
| verbose_proxy_logger.debug( | ||
| "pass_through_endpoint: response body not JSON-parseable, skipping post-call guardrails" | ||
| ) | ||
|
|
||
| ## LOG SUCCESS | ||
| passthrough_logging_payload["response_body"] = response_body | ||
| end_time = datetime.now() | ||
| asyncio.create_task( | ||
|
|
@@ -944,13 +978,47 @@ async def pass_through_request( # noqa: PLR0915 | |
| api_base=str(url._uri_reference), | ||
| ) | ||
|
|
||
| response_headers = HttpPassThroughEndpointHelpers.get_response_headers( | ||
| headers=response.headers, | ||
| custom_headers=custom_headers, | ||
| ) | ||
| if _content_modified: | ||
| response_headers.pop("content-length", None) | ||
|
|
||
| return Response( | ||
| content=content, | ||
| status_code=response.status_code, | ||
| headers=HttpPassThroughEndpointHelpers.get_response_headers( | ||
| headers=response.headers, | ||
| custom_headers=custom_headers, | ||
| ), | ||
| headers=response_headers, | ||
| ) | ||
| except ModifyResponseException as e: | ||
| verbose_proxy_logger.info( | ||
| "pass_through_endpoint: Guardrail %s modified response: %s", | ||
| e.guardrail_name, | ||
| str(e.message or "")[:200], | ||
| ) | ||
| try: | ||
| await proxy_logging_obj.post_call_failure_hook( | ||
| user_api_key_dict=user_api_key_dict, | ||
| original_exception=e, | ||
| request_data=e.request_data, | ||
| ) | ||
| except Exception: | ||
| verbose_proxy_logger.warning( | ||
| "pass_through_endpoint: post_call_failure_hook raised during guardrail block", | ||
| exc_info=True, | ||
| ) | ||
| error_body = { | ||
| "error": { | ||
| "message": e.message or "Response blocked by guardrail", | ||
| "type": "content_filter", | ||
| "guardrail_name": e.guardrail_name, | ||
| "model": e.model, | ||
| } | ||
| } | ||
| return Response( | ||
| content=json.dumps(error_body), | ||
| status_code=200, | ||
| media_type="application/json", | ||
| ) | ||
| except Exception as e: | ||
|
Comment on lines
+1010
to
1023
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The PR description even names the test return Response(
content=json.dumps(error_body),
status_code=200, # ← should be 200, not 400
media_type="application/json",
)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in a5c7abe — changed back to |
||
| custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a pre-existing cyclic import through
litellm/__init__.py— not introduced by this PR. Our change actually improved the situation: we moved the import target fromcustom_guardrail(which chains intocustom_logger→ proxy code) toexceptions(a leaf module with onlytyping,httpx,openai,litellm.types.utilsimports). The cycle CodeQL detects is the top-levellitellmpackage re-export chain that affects virtually every module in the codebase.