Skip to content

fix: cast deployment costs to float in cost-based routing - #33979

Open
devYRPauli wants to merge 2 commits into
BerriAI:litellm_internal_stagingfrom
devYRPauli:fix/lowest-cost-float-cast
Open

fix: cast deployment costs to float in cost-based routing#33979
devYRPauli wants to merge 2 commits into
BerriAI:litellm_internal_stagingfrom
devYRPauli:fix/lowest-cost-float-cast

Conversation

@devYRPauli

@devYRPauli devYRPauli commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Related to #32787, which made the same observation ("cost values read from model_info should always be cast to float"). The fix for that issue (#33556) was scoped to router.py:_set_model_group_info; router_strategy/lowest_cost.py was not covered and still sums the raw values.

Pre-Submission checklist

  • I have added meaningful tests (see "Proof of fix" below: verified with a
    standalone reproduction against the real routing path, not a unit test)
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

What is the problem

PyYAML only resolves scientific notation to a float when a decimal point is present:

>>> yaml.safe_load("a: 1e-06\nb: 1.0e-06\nc: 0.000002")
{'a': '1e-06', 'b': 1e-06, 'c': 2e-06}

So input_cost_per_token: 1e-06 in a proxy config arrives as a str, while 1.0e-06 arrives as a float. Both are natural things to write. lowest_cost.py then does item_cost = item_input_cost + item_output_cost with no cast.

Two failure modes follow:

  1. One deployment with a bare mantissa cost and another with a normal float cost. The costs are compared while sorting candidate deployments and raise TypeError: '<' not supported between instances of 'float' and 'str'. async_get_available_deployments does not catch this and the router re-raises, so the caller's completion request fails, not just the routing decision.

  2. Every deployment cost parsed as a string. There is no exception, but + concatenates instead of adding, so "1e-06" + "1e-06" becomes "1e-061e-06" and deployments are ordered by string comparison rather than by cost. Routing silently picks the wrong deployment.

The change

Wrap both values in a guarded float() and fall back to the existing model_cost defaults on failure, so the fallback behaviour and the selection semantics are unchanged. This matches the handling already used in router_strategy/quality_router.py (_get_deployment_input_cost) and router_strategy/complexity_router.py.

Proof of fix

Exercised the real LowestCostLoggingHandler.async_get_available_deployments with two deployments whose costs came from yaml.safe_load, so the string/float mix is produced the same way a proxy config produces it.

Before (commit 51b0b4a2ca, parent of this change):

(types: a=str, b=float)
mixed bare+float: RAISED TypeError: '<' not supported between instances of 'float' and 'str'
both bare: OK -> selected=a          # no crash, but costs were "1e-061e-06" and "2e-062e-06"

After (commit 8dfc8a5):

(types: a=str, b=float)
mixed bare+float: OK -> selected=a
both bare: OK -> selected=a

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a type-safety bug in lowest_cost.py where input_cost_per_token and output_cost_per_token values read from litellm_params could arrive as strings (PyYAML parses bare scientific notation like 1e-06 as str, not float), causing either a TypeError crash during deployment sorting or silent string-concatenation producing wrong routing decisions.

  • Both cost lookups are now wrapped in float() with a (ValueError, TypeError) guard, falling back to None so the existing model-cost-map default path is used — matching the pattern already in quality_router.py and complexity_router.py.
  • No test was committed despite the checklist marking that box; tests/local_testing/test_lowest_cost_routing.py has no case for YAML-sourced string costs, so CI won't catch a regression here.

Confidence Score: 4/5

Safe to merge — the logic change is minimal, correct, and consistent with the same pattern used elsewhere in the router strategy layer.

The fix is straightforward and well-scoped: two identical try/except float() blocks that match existing patterns in sibling files. The only gap is that no automated test was committed to the repo, so the two failure modes described in the PR (mixed-type crash and silent string-concatenation) are not enforced by CI after this merges.

tests/local_testing/test_lowest_cost_routing.py — should be extended with a test that injects YAML-parsed string costs to cover both the mixed-type and all-string scenarios.

Important Files Changed

Filename Overview
litellm/router_strategy/lowest_cost.py Wraps input_cost_per_token and output_cost_per_token lookups in float() with a try/except fallback to None, preventing both TypeError (mixed str/float comparisons) and silent string-concatenation bugs when PyYAML parses bare scientific-notation costs as strings.

Comments Outside Diff (1)

  1. litellm/router_strategy/lowest_cost.py, line 255-277 (link)

    P2 No test committed despite checklist claiming tests were added

    The PR's pre-submission checklist has "I have added meaningful tests" checked, but the diff contains no new or modified test files. The "Proof of fix" section in the description shows a manual local run, not a committed automated test. tests/local_testing/test_lowest_cost_routing.py has no case covering YAML-sourced string costs, so regression coverage for both failure modes described (mixed-type TypeError and silent string-concatenation) will not be enforced by CI going forward.

    Rule Used: What: Ensure that any PR claiming to fix an issue ... (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!

Reviews (1): Last reviewed commit: "Cast deployment costs to float in cost-b..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/router_strategy/lowest_cost.py 0.00% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

@devYRPauli devYRPauli changed the title Cast deployment costs to float in cost-based routing fix: cast deployment costs to float in cost-based routing Jul 20, 2026
@devYRPauli
devYRPauli changed the base branch from litellm_oss_daily_2026_07_17 to litellm_oss_daily_2026_07_20 July 20, 2026 18:19
@devYRPauli
devYRPauli force-pushed the fix/lowest-cost-float-cast branch from f3b831a to 7dfc56c Compare July 28, 2026 02:55
@devYRPauli
devYRPauli force-pushed the fix/lowest-cost-float-cast branch from 7dfc56c to 81feaeb Compare August 24, 2026 18:19
@devYRPauli
devYRPauli requested a review from a team August 24, 2026 18:19
@devYRPauli
devYRPauli changed the base branch from litellm_oss_daily_2026_07_20 to litellm_internal_staging August 24, 2026 18:19
@devYRPauli

Copy link
Copy Markdown
Contributor Author

Rebased onto litellm_internal_staging and retargeted.

This PR was based on litellm_oss_daily_2026_07_20. No new litellm_oss_daily_* branch has been cut since 2026_07_20, so that base is no longer maintained, and osv-scan fails there while it passes for PRs based on litellm_internal_staging. The Verify PR source branch guard now also tells external contributors to target litellm_internal_staging.

I cherry-picked the commit onto current litellm_internal_staging rather than only changing the base. Flipping the base alone pulled two unrelated commits into the diff, because they are ancestors of the old daily branch but not of staging.

The change itself is unchanged, and I re-checked that the bug is still present on staging before pushing.

devYRPauli and others added 2 commits August 24, 2026 14:39
PyYAML only resolves scientific notation to a float when a decimal point
is present, so input_cost_per_token: 1e-06 in a proxy config parses as a
string while 1.0e-06 parses as a float. lowest_cost.py summed those
values without casting.

With one string and one float cost the router raised TypeError while
sorting candidate deployments, and since async_get_available_deployments
does not catch it and the router re-raises, the caller's completion
request failed. When every cost parsed as a string the sum silently
concatenated instead, so deployments were ordered by string comparison
rather than cost.

Cast both values with a guarded float() and fall back to the existing
model_cost defaults, matching quality_router and complexity_router.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@devYRPauli
devYRPauli force-pushed the fix/lowest-cost-float-cast branch from 81feaeb to c5c1985 Compare August 24, 2026 18:39
@codspeed-hq

codspeed-hq Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing devYRPauli:fix/lowest-cost-float-cast (c5c1985) with litellm_internal_staging (d447be1)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (a626170) during the generation of this report, so d447be1 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

1 participant