fix(llm): fall back to non-Gemini model on Gemini content_filter (SKY-11766) (#7155)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run

This commit is contained in:
Shuchang Zheng 2026-07-07 07:52:03 -07:00 committed by GitHub
parent c14b6cb95f
commit 90e29f9fc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 185 additions and 5 deletions

View file

@ -34,6 +34,7 @@ from skyvern.forge.sdk.api.llm.exceptions import (
from skyvern.forge.sdk.api.llm.litellm_transport import configure_litellm_transport
from skyvern.forge.sdk.api.llm.ui_tars_response import UITarsResponse
from skyvern.forge.sdk.api.llm.utils import (
is_content_filtered_response,
is_image_message,
is_truncated_response,
llm_messages_builder,
@ -1378,6 +1379,43 @@ class LLMAPIHandlerFactory:
),
)
# A content_filter response with empty content is a valid ModelResponse,
# so litellm's router fallback (which only fires on exceptions) never
# recovers it. Gemini's non-configurable safety filters block PII-heavy
# prompts that safety_settings=BLOCK_NONE cannot recover, so retrying on
# another Gemini tier hits the same block — jump straight to the first
# non-Gemini fallback group. Fires for any Gemini model that produced the
# block, including a standard tier litellm already fell back to (SKY-11766).
non_gemini_fallback = next(
(group for group in fallback_groups if "gemini" not in group.lower()), None
)
if (
is_content_filtered_response(response)
and non_gemini_fallback is not None
and "gemini" in (model_used or "").lower()
):
fallback_model = non_gemini_fallback
LOG.warning(
"LLM response blocked by content filter on Gemini, retrying with non-Gemini fallback",
llm_key=llm_key,
prompt_name=prompt_name,
filtered_model=model_used,
fallback_model=fallback_model,
)
_llm_call_start = time.perf_counter()
try:
# No timeout= kwarg here either; see SKY-10200 note on
# the primary call site above.
response = await router.acompletion(
model=fallback_model,
messages=messages,
drop_params=True,
**parameters,
)
finally:
llm_duration_seconds += time.perf_counter() - _llm_call_start
model_used = response.model or fallback_model
# Error paths only set status=error, not token/cost attrs via
# _enrich_llm_span — no response object exists so there's nothing to report.
except litellm.exceptions.APIError as e:

View file

@ -190,6 +190,20 @@ def is_truncated_response(response: litellm.ModelResponse) -> bool:
)
def is_content_filtered_response(response: litellm.ModelResponse) -> bool:
# Gemini's non-configurable safety filters (prohibited-content / SPII) block PII-heavy
# prompts at the input stage, returning finish_reason=content_filter with empty content.
# This is a valid ModelResponse, so litellm's router fallback never fires — the caller
# must detect it and retry on a non-Gemini fallback explicitly (SKY-11766).
if not response.choices:
return False
choice = response.choices[0]
return (
getattr(choice, "finish_reason", None) == "content_filter"
and getattr(getattr(choice, "message", None), "content", None) is None
)
def parse_api_response(
response: litellm.ModelResponse, add_assistant_prefix: bool = False, force_dict: bool = True
) -> dict[str, Any] | Any: