Skip to content

fix(logging): recalculate cost after router retry failures#28476

Merged
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_fix_retry_zero_response_cost
May 21, 2026
Merged

fix(logging): recalculate cost after router retry failures#28476
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_fix_retry_zero_response_cost

Conversation

@milan-berri

@milan-berri milan-berri commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Relevant issues

Fixes zero spend on successful router-retried calls when intermediate failures pin response_cost to 0 (regression interaction with #21844).

Root cause: failure_handler sets response_cost = 0 on each failed attempt (#4604). PR #21844 added a preserve branch in _process_hidden_params_and_response_cost that kept any non-None response_cost, including 0 / 0.0, so a later success with full usage never recalculated cost.

How the fix helps the customer case: Router retries share one Logging object. Failed attempts set response_cost to 0; the successful Bedrock/passthrough response has real usage. We no longer treat that stale 0 as a pre-calculated cost—we recalculate from the success response so spend logs and cost_breakdown match token usage (~$0.03 for ~9.7k + 30 tokens). Pass-through handlers that set a non-zero cost (or response_cost on result._hidden_params) are unchanged.

Linear ticket

Resolves LIT-3261

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit (CI on PR)
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link: (fill after PR opened)

  • CI run for the last commit
    Link: (fill after PR opened)

  • Merge / cherry-pick CI run
    Links: (fill after PR opened)

Screenshots / Proof of Fix

Before (on litellm_internal_staging without this PR)

Simulate two failed attempts (failure_handlerresponse_cost = 0), then process a successful ModelResponse with usage (same shared Logging object as router retries):

cd berriai/litellm
source ../litellm-proxy/venv_berriai/bin/activate  # or your dev venv

python <<'PY'
from datetime import datetime
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import ModelResponse, Usage

logging_obj = Logging(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "hi"}],
    stream=False,
    call_type="acompletion",
    start_time=datetime.now(),
    litellm_call_id="repro",
    function_id="repro",
)
logging_obj.model_call_details["litellm_params"] = {"model": "openai/gpt-4o-mini"}
logging_obj.optional_params = {}

err = litellm.RateLimitError(message="rate limit", llm_provider="openai", model="openai/gpt-4o-mini")
for _ in range(2):
    logging_obj._failure_handler_helper_fn(
        exception=err, traceback_exception="", start_time=datetime.now(), end_time=datetime.now()
    )

resp = ModelResponse(
    id="ok",
    choices=[{"message": {"role": "assistant", "content": "ok"}}],
    usage=Usage(prompt_tokens=9698, completion_tokens=30, total_tokens=9728),
)
logging_obj._process_hidden_params_and_response_cost(resp, datetime.now(), datetime.now())
print("response_cost:", logging_obj.model_call_details.get("response_cost"))
print("slo response_cost:", (logging_obj.model_call_details.get("standard_logging_object") or {}).get("response_cost"))
PY

Expected before fix: response_cost: 0 and slo response_cost: 0.0 despite 9728 tokens (matches customer report on v1.85.0).

After (this branch)

Run all regression tests:

pytest tests/test_litellm/litellm_core_utils/test_litellm_logging.py::test_process_hidden_params_recalculates_cost_after_failure_handler_zero \
  tests/test_litellm/litellm_core_utils/test_litellm_logging.py::test_process_hidden_params_preserves_zero_cost_in_hidden_params \
  tests/test_litellm/litellm_core_utils/test_litellm_logging.py::test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zero \
  tests/test_litellm/litellm_core_utils/test_litellm_logging.py::test_async_success_handler_preserves_response_cost_for_pass_through_endpoints \
  -q

Expected after fix:

....                                                                     [100%]
4 passed

Manual check on this branch (same script as above) prints non-zero cost (~0.0014727 for gpt-4o-mini at 9698+30 tokens).

Test What it proves
test_process_hidden_params_recalculates_cost_after_failure_handler_zero Retry bug: failure 0 + success usage → non-zero cost
test_process_hidden_params_preserves_zero_cost_in_hidden_params Pass-through 0.0 on _hidden_params stays 0 (no regression)
test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zero After failures, _hidden_params cost wins over stale 0
test_async_success_handler_preserves_response_cost_for_pass_through_endpoints Positive pre-calculated pass-through cost preserved (#19887)

Customer symptom (context)

  • Bedrock allm_passthrough_route, attempted_retries: 2, ~9.7k prompt + 30 completion tokens, cost_breakdown all zeros on success row.
  • Not reproduced end-to-end on live proxy + HTTP 429 mock (OpenAI SDK internal retries / set_response_headers recalc differ from Bedrock passthrough path). Unit repro above matches the logging bug mechanism.

Greptile P2 (!= 0 vs > 0)

Greptile suggested existing_cost != 0 instead of > 0. For 0 and 0.0, == 0 is true in Python, so both guards recalculate the same way. We use != 0 per review; legitimate zero cost on result._hidden_params is still preserved via the earlier branch ("response_cost" in hidden_params).

Type

🐛 Bug Fix

Changes

  • litellm/litellm_core_utils/litellm_logging.py: In _process_hidden_params_and_response_cost, only preserve existing response_cost when it is != 0 (pass-through pre-calculated non-zero). Do not preserve 0 / 0.0 from failure_handler on intermediate router retries; recalculate from usage on success.
  • tests/test_litellm/litellm_core_utils/test_litellm_logging.py:
    • test_process_hidden_params_recalculates_cost_after_failure_handler_zero
    • test_process_hidden_params_preserves_zero_cost_in_hidden_params
    • test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zero

Do not preserve response_cost=0 from failure_handler when processing a
successful response; only keep pre-calculated costs > 0 (pass-through).

Co-authored-by: Cursor <[email protected]>
@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a zero-spend regression on router-retried calls where failure_handler sets response_cost = 0 on each failed attempt, and the preserve-existing-cost branch introduced by PR #21844 kept that stale 0 even after a successful response with real token usage.

  • litellm_logging.py: Tightens the preserve-existing-cost guard from is not None to is not None and existing_cost != 0, so a stale failure-handler 0 falls through to _response_cost_calculator on success. Zero costs set explicitly on result._hidden_params (pass-through handlers) are still honoured via the earlier \"response_cost\" in hidden_params branch.
  • test_litellm_logging.py: Adds four focused unit tests covering the retry regression, hidden-params zero preservation, hidden-params cost priority, and the existing pass-through preservation path.
  • test_amazing_vertex_completion.py: Converts transient Google Maps Platform InternalServerError responses from test failures to skips.

Confidence Score: 5/5

Safe to merge — the change is a single-line guard tightening in the cost-resolution branch, with four targeted unit tests that verify both the fixed path and the preserved pass-through paths.

The fix is minimal and well-contained: one walrus-operator condition in _process_hidden_params_and_response_cost. Pass-through zero costs are still preserved via the hidden_params branch that runs first, so no regression is introduced for that path. All affected code paths have corresponding unit tests.

No files require special attention.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/litellm_logging.py Adds != 0 guard to the existing-cost preserve branch so stale 0 from failure_handler on router retries no longer blocks cost recalculation on success
tests/test_litellm/litellm_core_utils/test_litellm_logging.py Adds four unit tests covering the retry-zero-cost regression, hidden-params zero preservation, hidden-params cost priority after failures, and pass-through cost preservation
tests/local_testing/test_amazing_vertex_completion.py Adds InternalServerError catch to skip (not fail) on transient upstream 500s from Google Maps Platform

Reviews (3): Last reviewed commit: "test(vertex): skip google maps tool test..." | Re-trigger Greptile

Comment thread litellm/litellm_core_utils/litellm_logging.py Outdated
Use != 0 for pre-calculated cost preservation (Greptile feedback). Add tests
for zero cost in _hidden_params and for hidden_params overriding failure 0.

Co-authored-by: Cursor <[email protected]>
@milan-berri

Copy link
Copy Markdown
Collaborator Author

@greptileai check again

The test test_gemini_google_maps_tool_simple calls real Vertex AI with the
googleMaps tool, which depends on Google Maps Platform. CI has been
failing on local_testing_part1 across many unrelated PRs (including this
one and the litellm_internal_staging base) with an InternalServerError
500 from Maps Platform ('Internal server error. Please retry. ...maps-
platform-support'), which is an external upstream flake unrelated to
the change under test.

Catch litellm.InternalServerError and skip (mirroring the existing
RateLimitError handler) so transient upstream outages don't block CI.
@mateo-berri

Copy link
Copy Markdown
Collaborator

@greptileai

@mateo-berri mateo-berri self-requested a review May 21, 2026 21:05

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM; thanks!

@mateo-berri mateo-berri merged commit b557492 into litellm_internal_staging May 21, 2026
117 checks passed
songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 5, 2026
…router retry (#74)

Backports BerriAI#28476 (merged 2026-05-22) to ship/v1.83.10.

## Root cause

When Router retries an LLM call, every attempt shares one `Logging`
instance. `failure_handler` sets `model_call_details["response_cost"] = 0`
on each failed attempt (introduced by upstream BerriAI#4604). The success path
in `_process_hidden_params_and_response_cost` then short-circuits via a
preserve branch (introduced by BerriAI#21844) that treats any non-None existing
cost as authoritative — including the stale 0 from the prior failed
attempt — so SpendLog records $0 despite a successful response with
real usage.

## Fix

Tighten the preserve branch with a walrus-bound `!= 0` guard so a stale
zero from failure_handler falls through to recalculation. Legitimate
zero costs (cache_hit, pass-through handlers writing
`result._hidden_params["response_cost"] = 0`) are still preserved by
the earlier two branches.

```python
elif (
    existing_cost := self.model_call_details.get("response_cost")
) is not None and existing_cost != 0:
    pass  # preserve pass-through cost
```

## Scope of backport

Cherry-picked from upstream merge commit b557492. Kept the
`litellm_logging.py` fix + the 3 new regression tests in
`tests/test_litellm/litellm_core_utils/test_litellm_logging.py`.
Dropped the `tests/local_testing/test_amazing_vertex_completion.py`
hunk — that's an upstream-CI Google Maps Platform flake skip,
unrelated to this fix and not run in our CI.

## Tier

C — universal bug fix in `litellm/` core. Already merged upstream
(BerriAI#28476), so this is purely a backport for v1.83.10.

## Verification

```
$ pytest tests/test_litellm/litellm_core_utils/test_litellm_logging.py \
    -k "process_hidden_params or async_success_handler_preserves" -v
... 4 passed in 0.72s
```

E2E reproduction (Key1 503 → fallback Key2 200) was previously
confirmed against this exact mechanism — spend went from 0 to
0.000968 with the fix applied.
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…8476)

* fix(logging): recalculate cost after router retry failures

Do not preserve response_cost=0 from failure_handler when processing a
successful response; only keep pre-calculated costs > 0 (pass-through).

Co-authored-by: Cursor <[email protected]>

* test(logging): guard pass-through zero cost; use != 0 preserve check

Use != 0 for pre-calculated cost preservation (Greptile feedback). Add tests
for zero cost in _hidden_params and for hidden_params overriding failure 0.

Co-authored-by: Cursor <[email protected]>

* test(vertex): skip google maps tool test on transient upstream 500

The test test_gemini_google_maps_tool_simple calls real Vertex AI with the
googleMaps tool, which depends on Google Maps Platform. CI has been
failing on local_testing_part1 across many unrelated PRs (including this
one and the litellm_internal_staging base) with an InternalServerError
500 from Maps Platform ('Internal server error. Please retry. ...maps-
platform-support'), which is an external upstream flake unrelated to
the change under test.

Catch litellm.InternalServerError and skip (mirroring the existing
RateLimitError handler) so transient upstream outages don't block CI.

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: mateo-berri <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants