Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions litellm/llms/anthropic/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
RESPONSE_FORMAT_TOOL_NAME,
)
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
Expand Down Expand Up @@ -63,7 +62,6 @@
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
ChatCompletionToolParam,
OpenAIChatCompletionFinishReason,
OpenAIMcpServerTool,
OpenAIWebSearchOptions,
)
Expand All @@ -77,6 +75,7 @@
CompletionTokensDetailsWrapper,
PromptTokensDetailsWrapper,
ServerToolUse,
map_finish_reason_and_stash_native,
)
from litellm.types.utils import Message as LitellmMessage
from litellm.utils import (
Expand Down Expand Up @@ -2626,10 +2625,13 @@ def transform_parsed_response(

model_response.choices[0].message = _message
model_response._hidden_params["original_response"] = completion_response["content"]
model_response.choices[0].finish_reason = cast(
OpenAIChatCompletionFinishReason,
map_finish_reason(completion_response["stop_reason"]),
_choice = model_response.choices[0]
_mapped_reason, _provider_specific_fields = map_finish_reason_and_stash_native(
completion_response["stop_reason"], getattr(_choice, "provider_specific_fields", None)
)
_choice.finish_reason = _mapped_reason
if _provider_specific_fields is not None:
setattr(_choice, "provider_specific_fields", _provider_specific_fields)

usage: Final = self.calculate_usage(
usage_object=completion_response["usage"],
Expand Down
157 changes: 157 additions & 0 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,20 @@ def as_output_cap(value: object) -> int | None:
return cap if cap >= 0 else None


## Exception classes a treat_finish_reason_as_failure value may name: resolved from litellm at
## use time, validated at Router construction.
Comment on lines +711 to +712

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Narrative Comments Violate Policy These labels violate the repository rule limiting comments to essential complex logic, tool inputs, or TODOs. Remove them before merging

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the two module-level notes: the ## label blocks match the surrounding house style, including the ## CHECK CONTENT FILTER ERROR ## checkpoints this change extends. They carry the allowlist semantics a reader cannot get from the frozenset alone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That context is fair. These are concise semantic labels rather than narrative comments: they document the allowlist’s purpose and the healthy-reason warning, and they match the surrounding ## checkpoint style. I retract the comment; no change is needed.

_FINISH_REASON_FAILURE_EXCEPTION_NAMES: Final = frozenset(
{
"RateLimitError",
"APIError",
"BadRequestError",
"Timeout",
"ServiceUnavailableError",
"InternalServerError",
}
)


class Router:
model_names: set = set()
cache_responses: bool | None = False
Expand Down Expand Up @@ -752,6 +766,7 @@ def __init__(
fallbacks: list = [],
context_window_fallbacks: list = [],
content_policy_fallbacks: list = [],
treat_finish_reason_as_failure: dict[str, str] | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Invalid Settings Break Restart /config/update persists this argument without validation. Invalid mappings are ignored live but fail the next Router startup

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the update path: /config/update validates keys, persists router_settings to the config store, and never constructs a Router in place, so an invalid value surfaces at the next startup. That is the existing behavior for every init-validated Router parameter, and the ValueError names the offending value, so the failure is loud and actionable. Nothing specific to this knob.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for verifying. Since /config/update already persists init-validated router_settings without reconstructing Router, and construction raises an explicit ValueError on restart, this is existing behavior shared by other Router parameters rather than a regression specific to this knob. I withdraw the finding.

model_group_alias: dict[str, str | RouterModelGroupAliasItem] | None = {},
enable_pre_call_checks: bool = False,
enable_tag_filtering: bool = False,
Expand Down Expand Up @@ -1072,6 +1087,33 @@ def __init__(
_content_policy_fallbacks: Final = content_policy_fallbacks or litellm.content_policy_fallbacks
self.validate_fallbacks(fallback_param=_content_policy_fallbacks)
self.content_policy_fallbacks = _content_policy_fallbacks

## treat_finish_reason_as_failure: map a terminal finish/stop reason on a 200 response to a
## router-understood exception class, so the mapped reason engages allowed_fails/cooldowns/
## fallbacks like any failure. Reason strings are matched exactly.
if treat_finish_reason_as_failure is not None:
for exception_name in treat_finish_reason_as_failure.values():
if exception_name not in _FINISH_REASON_FAILURE_EXCEPTION_NAMES:
raise ValueError(
f"treat_finish_reason_as_failure values must be one of {sorted(_FINISH_REASON_FAILURE_EXCEPTION_NAMES)}, got {exception_name}"
)
self.treat_finish_reason_as_failure = treat_finish_reason_as_failure
if treat_finish_reason_as_failure:
verbose_router_logger.warning(
"treat_finish_reason_as_failure applies to non-streaming responses only; a streamed 200 with the mapped stop reason is delivered unchanged."
)
healthy_terminal_keys: Final = treat_finish_reason_as_failure.keys() & {
"stop",
"length",
"tool_calls",
"function_call",
}
if healthy_terminal_keys:
verbose_router_logger.warning(
"treat_finish_reason_as_failure keys %s are healthy terminal reasons in the mapped OpenAI set; mapping them fails successful responses. Keys are matched against provider-native stop reasons.",
sorted(healthy_terminal_keys),
)

self.total_calls: defaultdict = defaultdict(int) # dict to store total calls made to each model
self.fail_calls: defaultdict = defaultdict(int) # dict to store fail_calls made to each model
self.success_calls: defaultdict = defaultdict(int) # dict to store success_calls made to each model
Expand Down Expand Up @@ -2549,6 +2591,14 @@ def _completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> M
llm_provider="",
)

## CHECK MAPPED FINISH REASON ERROR ##
if isinstance(response, ModelResponse):
_mapped_reason = self._get_mapped_finish_reason(response)
if _mapped_reason is not None:
self._handle_mapped_finish_reason_failure(
model=model, deployment=deployment, reason=_mapped_reason, kwargs=kwargs
)

if (
isinstance(response, CustomStreamWrapper)
and response.completion_stream is None
Expand Down Expand Up @@ -3673,6 +3723,14 @@ async def _acompletion(
llm_provider="",
)

## CHECK MAPPED FINISH REASON ERROR ##
if isinstance(response, ModelResponse):
_mapped_reason = self._get_mapped_finish_reason(response)
if _mapped_reason is not None:
self._handle_mapped_finish_reason_failure(
model=model, deployment=deployment, reason=_mapped_reason, kwargs=kwargs
)

if (
isinstance(response, CustomStreamWrapper)
and response.completion_stream is None
Expand Down Expand Up @@ -5390,6 +5448,17 @@ async def _ageneric_api_call_with_fallbacks_helper(self, model: str, original_ge
refusal_details: Final = cast(dict, response["stop_details"]) # cast-ok: gate verified the shape
raise safeguard_refusal_error(model=model, stop_details=refusal_details)

if (
self.treat_finish_reason_as_failure
and getattr(original_generic_function, "__name__", "") == "anthropic_messages"
and isinstance(response, dict)
):
stop_reason: Final = response.get("stop_reason")
if stop_reason in self.treat_finish_reason_as_failure:
self._handle_mapped_finish_reason_failure(
model=model, deployment=deployment, reason=stop_reason, kwargs=kwargs
)

self.success_calls[model_name] += 1
verbose_router_logger.info("ageneric_api_call_with_fallbacks(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)

Expand Down Expand Up @@ -8580,6 +8649,34 @@ def _refusal_fallback_available(self, model_group: str, kwargs: Mapping[str, Any
content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self.content_policy_fallbacks)
if content_policy_fallbacks is not None:
return self._has_content_policy_fallback(model_group, kwargs)
return self._generic_fallback_available(model_group, kwargs)

def _get_mapped_finish_reason(self, response: ModelResponse) -> str | None:
"""
The finish reason configured in treat_finish_reason_as_failure that this response carries,
or None. Checks both the mapped finish_reason and the pre-mapping value stashed in
provider_specific_fields["native_finish_reason"]. Streaming detection is a follow-up
modeled on _aanthropic_messages_streaming_iterator.
"""
if not self.treat_finish_reason_as_failure:
return None
if not (response.choices and len(response.choices) > 0):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Later Choices Bypass Detection Only choices[0] is inspected, so configured failures in later choices bypass cooldown and fallback handling

Knowledge Base Used: Model invocation runtime

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the choices[0] read: it matches the adjacent _should_raise_content_policy_error check this knob generalizes (same block, two lines above), and a single completion carries one terminal reason across its choices. Consistency with the sibling check is the behavior a reviewer can predict here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. _should_raise_content_policy_error is the adjacent precedent in the same block, and this response shape carries one terminal reason across its choices, so reading choices[0] is intentional and predictable here. I’m withdrawing this finding; no change is needed.

choice: Final = response.choices[0]
if choice.finish_reason in self.treat_finish_reason_as_failure:
return choice.finish_reason
native_reason: Final = (getattr(choice, "provider_specific_fields", None) or {}).get("native_finish_reason")
if native_reason in self.treat_finish_reason_as_failure:
return native_reason
return None

def _generic_fallback_available(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
"""
Whether a generic fallback can serve a retry: default fallbacks set, or a generic chain
resolving for this request. Shared tail of the fallback-availability gates.
"""
if fallbacks_disabled_for_request(kwargs):
return False
if self._has_default_fallbacks():
return True
fallbacks: Final = kwargs.get("fallbacks", self.fallbacks)
Expand All @@ -8591,6 +8688,66 @@ def _refusal_fallback_available(self, model_group: str, kwargs: Mapping[str, Any
)
return resolved is not None

def _handle_mapped_finish_reason_failure(self, model: str, deployment: dict, reason: str, kwargs: dict) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Parameters Violate Typing Policy Bare dict and Any parameters violate the repository's fully typed requirement. Replace them with precise types before merging

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new helper signatures use Mapping[str, Any], and the earlier bare dict parameters were converted in 2210f94: the repository's type-discipline gate holds every LIT rule at or below the base count on this branch. Any as the value type matches the file-wide convention.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The new helper signatures in litellm/router.py:8691 use Mapping[str, Any], and the earlier bare dict parameters were converted in 2210f94. Since Any matches the file-wide convention and the type-discipline count is not regressing, the typing finding is resolved. No further change is needed.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

"""
Account for a mapped finish-reason failure, then raise the configured exception into the
fallback chain when a generic fallback can serve. Accounting happens before the gate:
the raise lands after the 200 came back, so litellm's failure callbacks never fire for
it, and this is the only path that parks the deployment. A deployment with no model_info
id cannot be accounted or parked, but the raise still applies to it.
"""
exception: Final = self._account_mapped_finish_reason_failure(
model=model, deployment=deployment, reason=reason, kwargs=kwargs
) or self._finish_reason_failure_error(model=model, reason=reason)
if self._generic_fallback_available(model, kwargs):
raise exception

def _finish_reason_failure_error(self, model: str, reason: str) -> Exception:
"""Build the exception instance configured for a mapped finish reason."""
exception_name: Final = self.treat_finish_reason_as_failure[reason]
exception_cls: Final = getattr(litellm, exception_name)
message: Final = f"Response finished with reason '{reason}' (treat_finish_reason_as_failure)."
if exception_name == "APIError":
return exception_cls(status_code=500, message=message, llm_provider="", model=model)
return exception_cls(message=message, llm_provider="", model=model)

def _account_mapped_finish_reason_failure(
self, model: str, deployment: dict, reason: str, kwargs: dict
) -> Exception | None:
"""
Count and park a mapped finish-reason failure: increment the per-minute failure counter
and set the cooldown, honoring a deployment-level cooldown_time like
deployment_callback_on_failure does (the retry-after-header tier has no counterpart
here: the exception is synthesized, it carries no response headers). Returns the built
exception so the caller can raise the same instance it accounted for, or None when the
deployment has no id to account against.
"""
model_info: Final = deployment.get("model_info") or {}
deployment_id: Final = model_info.get("id") if isinstance(model_info, dict) else None
if deployment_id is None:
return None
litellm_params: Final = deployment.get("litellm_params") or {}
deployment_cooldown: Final = _first_present(
model_info if isinstance(model_info, dict) else None, litellm_params, key="cooldown_time"
)
time_to_cooldown: Final = (
deployment_cooldown if deployment_cooldown is not None and deployment_cooldown >= 0 else self.cooldown_time
)
exception: Final = self._finish_reason_failure_error(model=model, reason=reason)
increment_deployment_failures_for_current_minute(
litellm_router_instance=self,
deployment_id=deployment_id,
)
_set_cooldown_deployments(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Caller-controlled responses poison shared deployment health

Finish reasons can depend on caller-controlled input, such as prompt size or output limits. When one of those reasons is configured, a client can repeatedly trigger it to increment the deployment-wide failure counter and cool down the deployment for every tenant; with RateLimitError and multiple deployments, the cooldown can occur after one request. Keep fallback handling request-scoped for caller-induced reasons, or require a separate explicit option before applying them to shared failure and cooldown accounting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known and deliberate. The same caller-induced parking already applies to content_policy_fallbacks today (a client whose prompts trip content filters cools the deployment for everyone), so the knob inherits an existing operator tradeoff rather than introducing one. Construction warns when a key names a healthy terminal reason such as stop or length, which is the caller-controlled vector; mapping provider-quota reasons like model_context_window_exceeded is the intended use and is not caller-controlled.

litellm_router_instance=self,
exception_status=exception.status_code,
original_exception=exception,
deployment=deployment_id,
time_to_cooldown=time_to_cooldown,
requested_model_group=(get_litellm_metadata_from_kwargs(kwargs) or {}).get("model_group"),
)
return exception

def _should_raise_content_policy_error(self, model: str, response: ModelResponse, kwargs: dict) -> bool:
"""
Determines if a content policy error should be raised.
Expand Down
20 changes: 16 additions & 4 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,19 @@ def __setitem__(self, key, value) -> None:
setattr(self, key, value)


def map_finish_reason_and_stash_native(
finish_reason: str, provider_specific_fields: dict[str, Any] | None
) -> tuple[OpenAIChatCompletionFinishReason, dict[str, Any] | None]:
"""Map a provider-native finish reason to the OpenAI set; when the native value differs
from the mapped one, preserve it under provider_specific_fields["native_finish_reason"]
so downstream consumers can still see what the provider actually sent."""
mapped: Final = map_finish_reason(finish_reason)
if finish_reason != mapped:
provider_specific_fields = dict(provider_specific_fields) if provider_specific_fields else {}
provider_specific_fields["native_finish_reason"] = finish_reason
return mapped, provider_specific_fields


class Choices(SafeAttributeModel, OpenAIObject):
finish_reason: OpenAIChatCompletionFinishReason
index: int
Expand All @@ -1586,11 +1599,10 @@ def __init__(
**params,
) -> None:
if finish_reason is not None:
mapped: Final = map_finish_reason(finish_reason)
mapped, provider_specific_fields = map_finish_reason_and_stash_native(
finish_reason, provider_specific_fields
)
params["finish_reason"] = mapped
if finish_reason != mapped:
provider_specific_fields = dict(provider_specific_fields) if provider_specific_fields else {}
provider_specific_fields["native_finish_reason"] = finish_reason
else:
params["finish_reason"] = "stop"
if index is not None:
Expand Down
Loading
Loading