Skip to content

fix(pricing): resolve model prices by longest prefix, not first match - #198

Open
thegoodengineer wants to merge 2 commits into
lemony-ai:mainfrom
thegoodengineer:fix/longest-prefix-model-pricing
Open

fix(pricing): resolve model prices by longest prefix, not first match#198
thegoodengineer wants to merge 2 commits into
lemony-ai:mainfrom
thegoodengineer:fix/longest-prefix-model-pricing

Conversation

@thegoodengineer

Copy link
Copy Markdown

🎯 Description

Model pricing tables in this repo are keyed by model family (gpt-4o), but callers pass concrete model ids (gpt-4o-mini-2024-07-18). Four lookups resolved the concrete id by scanning the table and returning the first prefix hit. Because dicts preserve insertion order, whichever family happened to be declared first won, and a cheap variant got billed at the rate of the more expensive family whose name it extends.

gpt-4o-mini starts with gpt-4o, so it matched gpt-4o. In the LiteLLM fallback table it matched gpt-4 and picked up GPT-4 rates.

Affected lookups:

Location Example input Resolved as Overcharge
PriceBook.get() gpt-4o-mini-2024-07-18 gpt-4o 16.7x
LiteLLMCostProvider._fallback_cost() gpt-4o-mini-2024-07-18 gpt-4 120x
CostCalculator._estimate_fallback_cost() gpt-4o-mini gpt-4o 16.7x
OpenRouterProvider._calculate_cost() openai/o1-mini-2024-09-12 openai/o1 5x

gpt-5-mini and gpt-5-nano collapse onto gpt-5 the same way (5x and 25x).

Why this one stings: the mispriced models are the small ones, and small models are what a cascade drafts with. gpt-4o-mini alone appears in five of the built-in presets as a cheap-tier model. So the error lands on exactly the side of the cascade that is supposed to be cheap, and it inflates the savings figure the library exists to report.

With a gpt-4o-mini-2024-07-18 drafter against a gpt-4o-2024-08-06 verifier, both ids collapsed onto gpt-4o, so a cascade that really saves 94% reported 0% savings.

The fix is to match the longest key rather than the first, which makes resolution independent of table order. This is not a new convention: providers/openai.py and providers/anthropic.py already do exactly this (max(matches, key=len)), and integrations/langchain/models.py sorts longest-first for the same reason. I pulled that logic into one small helper so the remaining four tables stop hand-rolling it.

🔗 Related Issues

🔄 Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)

🧪 Testing

New file tests/test_pricing_prefix_matching.py, 23 tests. 16 of them fail on main and pass here, covering all four lookups.

The tests also pin down the behaviour that must not change:

  • unknown models still fall back to their table defaults
  • exact matches are unaffected
  • CostCalculator's OpenAI fallback is asserted against _openai_model_pricing, so the two OpenAI tables can no longer silently drift apart

Test cases added

  • Unit tests

Full suite

1254 passed, 42 skipped, 59 deselected

1231 passed before this branch, so no regressions and 23 added.

Also run locally with the versions CI pins:

  • black --check cascadeflow tests examples (black 25.11.0) clean
  • ruff check cascadeflow tests examples (ruff 0.15.0) clean
  • bandit -r cascadeflow/ -ll no medium or high findings
  • syntax checked against Python 3.9, the minimum supported version

mypy cascadeflow --ignore-missing-imports fails identically on main and on this branch in my environment (numpy stub needs a newer python_version than pyproject.toml sets), so it is untouched by this change.

How to test

from cascadeflow.pricing import PriceBook

book = PriceBook()
for model in ("gpt-4o-mini-2024-07-18", "gpt-4o-2024-08-06", "o1-mini-2024-09-12"):
    price = book.get(model)
    print(f"{model:24s} in={price.input_per_1k:<9} out={price.output_per_1k}")

Before:

gpt-4o-mini-2024-07-18   in=0.0025    out=0.01
gpt-4o-2024-08-06        in=0.0025    out=0.01
o1-mini-2024-09-12       in=0.015     out=0.06

After:

gpt-4o-mini-2024-07-18   in=0.00015   out=0.0006
gpt-4o-2024-08-06        in=0.0025    out=0.01
o1-mini-2024-09-12       in=0.003     out=0.012

📋 Checklist

Code Quality

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added type hints where appropriate
  • I have run black to format my code
  • I have run ruff and fixed all linting issues
  • I have run mypy for type checking

Testing

  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes
  • I have tested error cases and edge conditions
  • I have tested with the minimum supported Python version (3.9)

Documentation

  • I have added docstrings to new functions/classes
  • CHANGELOG.md not updated: it looks like it is only revised at release time, so I left it alone. Happy to add an entry if you'd rather.

Dependencies

  • No new dependencies

Breaking Changes

  • This PR includes NO breaking changes

Costs go down for correctly-priced small models, never up. Exact matches, unmatched models, and the Anthropic and LiteLLM override tables are unaffected, since those tables already had their keys ordered longest-first. The change makes that ordering no longer load-bearing.

📊 Performance Impact

Negligible. The helper builds a list of matching keys and takes the longest instead of breaking on the first hit, so it scans the whole table rather than stopping early. These tables hold on the order of ten to twenty entries and are consulted once per response.

🔐 Security Considerations

  • This PR does not introduce security vulnerabilities
  • I have not exposed sensitive information

Notes for the reviewer

One judgement call worth flagging: I added cascadeflow/pricing/matching.py rather than repeating max(matches, key=len) a fourth, fifth and sixth time. It is deliberately free of cascadeflow imports so providers/ and telemetry/ can use it without an import cycle.

I did not touch providers/openai.py, providers/anthropic.py or integrations/langchain/models.py. They are already correct, and rewriting working code felt like scope creep for a bug fix. They could adopt the helper in a follow-up if you want the duplication gone.

providers/groq.py has the same first-match pattern but no key in its table is a prefix of another, so it is not currently affected. I left it as is and did not want to widen the diff, though it is fragile the next time a model is added.

Pricing tables are keyed by model family, but callers pass concrete ids like
gpt-4o-mini-2024-07-18. Four lookups resolved those by scanning the table and
taking the first prefix hit, so the winner depended on dict insertion order and
a cheap variant was priced as the expensive family it extends:

  - PriceBook.get()                      gpt-4o-mini-* -> gpt-4o     (16.7x)
  - LiteLLMCostProvider._fallback_cost() gpt-4o-mini-* -> gpt-4      (120x)
  - CostCalculator fallback rates        gpt-4o-mini   -> gpt-4o     (16.7x)
  - OpenRouterProvider._calculate_cost() o1-mini-*     -> o1         (5x)

This mostly hit the small models used as drafters, so it inflated exactly the
side of the cascade that is supposed to be cheap. With a gpt-4o-mini-2024-07-18
drafter against a gpt-4o-2024-08-06 verifier, both ids collapsed onto gpt-4o and
reported 0% savings for a cascade that actually saves 94%.

Match the longest key instead, which makes resolution independent of table
order. providers/openai.py and providers/anthropic.py already do this; the
shared helper is factored out so the remaining tables use one implementation.

Behaviour is unchanged for exact matches, for models with no prefix hit, and
for the anthropic and litellm override tables, whose keys were already ordered
longest-first.
Each of the four pricing tables is checked against the family a pinned id
should resolve to, plus the cascade-savings case that motivated the fix.

Also covers the paths that must not change: unknown models still fall back to
their table defaults, and the CostCalculator fallback is pinned against
_openai_model_pricing so the two OpenAI tables cannot drift apart.

16 of these fail on the previous first-match lookup.
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