forked from BerriAI/litellm
-
Notifications
You must be signed in to change notification settings - Fork 0
fix: de-dup jp.anthropic.claude-sonnet-4-6 and fail CI on duplicate pricing keys #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
blackflame007
merged 1 commit into
litellm_internal_staging
from
admin/nol-90-dedup-dup-keys
Jul 28, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| #!/usr/bin/env python3 | ||
| """Gating guard: the model-price maps must not contain duplicate JSON keys. | ||
|
|
||
| ``json.load`` silently keeps the LAST occurrence of a repeated key, so a duplicated | ||
| model entry parses cleanly, resolves deterministically, and still ships the wrong | ||
| pricing: whichever entry a human reads in the file may not be the one that takes | ||
| effect. Two of these have already been inherited from upstream | ||
| (``gemini-omni-flash-preview``, ``jp.anthropic.claude-sonnet-4-6``), and each cost | ||
| real time to diagnose because every ordinary validator -- ``jq empty`` included -- | ||
| accepts them. | ||
|
|
||
| This check re-parses each price map with an ``object_pairs_hook`` that inspects the | ||
| raw key/value pairs BEFORE they collapse into a dict, so a repeat is caught instead | ||
| of swallowed. It walks every nesting level, not just the top-level model map. | ||
|
|
||
| Usage: | ||
| python scripts/check_model_prices_duplicate_keys.py [FILE ...] | ||
|
|
||
| With no arguments it checks the two tracked price maps. Exits 1 on any duplicate. | ||
| Stdlib only. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import sys | ||
| from collections import Counter | ||
| from pathlib import Path | ||
| from typing import Any, NamedTuple | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent | ||
|
|
||
| DEFAULT_TARGETS: tuple[str, ...] = ( | ||
| "model_prices_and_context_window.json", | ||
| "litellm/model_prices_and_context_window_backup.json", | ||
| ) | ||
|
|
||
|
|
||
| class DuplicateKey(NamedTuple): | ||
| """A key that appeared more than once inside a single JSON object.""" | ||
|
|
||
| key: str | ||
| count: int | ||
| lines: tuple[int, ...] | ||
|
|
||
|
|
||
| def find_duplicate_keys(path: Path) -> list[DuplicateKey]: | ||
| """Return every key repeated within one JSON object in ``path``. | ||
|
|
||
| Duplicates are read off the pair list handed to ``object_pairs_hook``, which | ||
| still holds both occurrences; the dict it returns does not. | ||
| """ | ||
| raw = path.read_text() | ||
| repeats: Counter[str] = Counter() | ||
|
|
||
| def hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]: | ||
| counts = Counter(key for key, _ in pairs) | ||
| for key, count in counts.items(): | ||
| if count > 1: | ||
| # Keep the highest count seen for this key across all objects. | ||
| repeats[key] = max(repeats[key], count) | ||
| return dict(pairs) | ||
|
|
||
| json.loads(raw, object_pairs_hook=hook) | ||
|
|
||
| duplicates: list[DuplicateKey] = [] | ||
| for key, count in sorted(repeats.items()): | ||
| needle = f'"{key}":' | ||
| lines = tuple( | ||
| number | ||
| for number, line in enumerate(raw.splitlines(), start=1) | ||
| if needle in line | ||
| ) | ||
| duplicates.append(DuplicateKey(key=key, count=count, lines=lines)) | ||
| return duplicates | ||
|
|
||
|
|
||
| def check(path: Path) -> bool: | ||
| """Check one file. True when clean, False when duplicates (or errors) found.""" | ||
| try: | ||
| duplicates = find_duplicate_keys(path) | ||
| except FileNotFoundError: | ||
| print(f"FAIL {path}: file not found", file=sys.stderr) | ||
| return False | ||
| except json.JSONDecodeError as exc: | ||
| print(f"FAIL {path}: invalid JSON - {exc}", file=sys.stderr) | ||
| return False | ||
|
|
||
| if not duplicates: | ||
| print(f"OK {path}: no duplicate keys") | ||
| return True | ||
|
|
||
| print(f"FAIL {path}: {len(duplicates)} duplicate key(s)", file=sys.stderr) | ||
| for dup in duplicates: | ||
| where = ", ".join(str(line) for line in dup.lines) or "unknown" | ||
| print( | ||
| f" {dup.key!r} appears {dup.count}x in the same object " | ||
| f"(lines: {where})", | ||
| file=sys.stderr, | ||
| ) | ||
| print( | ||
| "\n json.load keeps the LAST occurrence, so the earlier entry is dead text\n" | ||
| " that still reads as authoritative. Delete whichever entry is stale and\n" | ||
| " keep the one that matches the other price map and its sibling models.", | ||
| file=sys.stderr, | ||
| ) | ||
| return False | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None) -> int: | ||
| parser = argparse.ArgumentParser( | ||
| description=( | ||
| "Fail if a model-price JSON file repeats a key inside an object." | ||
| ), | ||
| ) | ||
| parser.add_argument( | ||
| "files", | ||
| nargs="*", | ||
| help=f"JSON files to check (default: {', '.join(DEFAULT_TARGETS)})", | ||
| ) | ||
| args = parser.parse_args(argv) | ||
|
|
||
| paths = ( | ||
| [Path(f) for f in args.files] | ||
| if args.files | ||
| else [REPO_ROOT / target for target in DEFAULT_TARGETS] | ||
| ) | ||
|
|
||
| results = [check(path) for path in paths] | ||
| return 0 if all(results) else 1 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
148 changes: 148 additions & 0 deletions
148
tests/test_litellm/test_model_prices_no_duplicate_keys.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| """Regression + self-test for the duplicate-key guard on the model-price maps. | ||
|
|
||
| Upstream has twice handed us a price map with the same model key written twice | ||
| (`gemini-omni-flash-preview` in NOL-79, `jp.anthropic.claude-sonnet-4-6` in | ||
| NOL-90). `json.load` keeps the LAST occurrence, so both parsed fine and both | ||
| shipped an entry that did not match the one a reader would find first in the | ||
| file. `jq empty` -- the only validation the price maps had -- accepts duplicates, | ||
| so nothing caught either case. | ||
|
|
||
| Two things are pinned here: | ||
|
|
||
| 1. the tracked price maps carry no duplicate keys, and | ||
| 2. the guard actually FAILS on a duplicate. A guard that has only ever been | ||
| observed passing is not a guard, so the negative cases are asserted too. | ||
| """ | ||
|
|
||
| import importlib.util | ||
| import json | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parents[2] | ||
| _MODULE_PATH = REPO_ROOT / "scripts" / "check_model_prices_duplicate_keys.py" | ||
| _spec = importlib.util.spec_from_file_location( | ||
| "check_model_prices_duplicate_keys", _MODULE_PATH | ||
| ) | ||
| guard = importlib.util.module_from_spec(_spec) | ||
| _spec.loader.exec_module(guard) | ||
|
|
||
|
|
||
| PRICE_MAPS = [ | ||
| "model_prices_and_context_window.json", | ||
| "litellm/model_prices_and_context_window_backup.json", | ||
| ] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("relative_path", PRICE_MAPS) | ||
| def test_price_map_has_no_duplicate_keys(relative_path): | ||
| """The shipped price maps must not repeat a key inside any object.""" | ||
| path = REPO_ROOT / relative_path | ||
| duplicates = guard.find_duplicate_keys(path) | ||
| assert duplicates == [], ( | ||
| f"{relative_path} repeats {[d.key for d in duplicates]}. " | ||
| "json.load keeps the last occurrence, so the earlier entry is dead text " | ||
| "that still reads as authoritative - delete the stale one." | ||
| ) | ||
|
|
||
|
|
||
| def test_jp_anthropic_claude_sonnet_4_6_matches_across_price_maps(): | ||
| """NOL-90: the backup's winning entry had drifted from the canonical map. | ||
|
|
||
| The duplicate's second (winning) copy was an older shape missing the 1-hour | ||
| cache-write tier, so the backup map silently priced jp. 1hr cache writes at | ||
| the 5-minute rate while the root map had it right. | ||
| """ | ||
| model = "jp.anthropic.claude-sonnet-4-6" | ||
| with open(REPO_ROOT / "model_prices_and_context_window.json") as f: | ||
| root = json.load(f) | ||
| with open(REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json") as f: | ||
| backup = json.load(f) | ||
|
|
||
| assert backup[model] == root[model], ( | ||
| f"{model} differs between the price map and its backup copy" | ||
| ) | ||
| assert backup[model]["cache_creation_input_token_cost_above_1hr"] == 6.6e-06 | ||
|
|
||
|
|
||
| def test_guard_detects_duplicate_top_level_key(tmp_path): | ||
| """The exact shape NOL-90 fixed: one model key written twice.""" | ||
| path = tmp_path / "dup.json" | ||
| path.write_text( | ||
| '{\n' | ||
| ' "a-model": {"input_cost_per_token": 1e-06},\n' | ||
| ' "jp.anthropic.claude-sonnet-4-6": {"input_cost_per_token": 3.3e-06},\n' | ||
| ' "jp.anthropic.claude-sonnet-4-6": {"input_cost_per_token": 9.9e-06}\n' | ||
| '}\n' | ||
| ) | ||
|
|
||
| # json.load is blind to this - that is the whole problem. | ||
| assert json.loads(path.read_text())["jp.anthropic.claude-sonnet-4-6"] == { | ||
| "input_cost_per_token": 9.9e-06 | ||
| } | ||
|
|
||
| duplicates = guard.find_duplicate_keys(path) | ||
| assert [d.key for d in duplicates] == ["jp.anthropic.claude-sonnet-4-6"] | ||
| assert duplicates[0].count == 2 | ||
| assert duplicates[0].lines == (3, 4) | ||
| assert guard.check(path) is False | ||
| assert guard.main([str(path)]) == 1 | ||
|
|
||
|
|
||
| def test_guard_detects_duplicate_nested_key(tmp_path): | ||
| """Duplicates below the top level count too (e.g. a repeated pricing field).""" | ||
| path = tmp_path / "nested.json" | ||
| path.write_text( | ||
| '{\n' | ||
| ' "a-model": {\n' | ||
| ' "mode": "chat",\n' | ||
| ' "mode": "video_generation"\n' | ||
| ' }\n' | ||
| '}\n' | ||
| ) | ||
|
|
||
| duplicates = guard.find_duplicate_keys(path) | ||
| assert [d.key for d in duplicates] == ["mode"] | ||
| assert guard.main([str(path)]) == 1 | ||
|
|
||
|
|
||
| def test_guard_passes_on_clean_file(tmp_path): | ||
| path = tmp_path / "clean.json" | ||
| path.write_text( | ||
| '{\n' | ||
| ' "a-model": {"mode": "chat"},\n' | ||
| ' "b-model": {"mode": "video_generation"}\n' | ||
| '}\n' | ||
| ) | ||
|
|
||
| assert guard.find_duplicate_keys(path) == [] | ||
| assert guard.check(path) is True | ||
| assert guard.main([str(path)]) == 0 | ||
|
|
||
|
|
||
| def test_guard_reports_every_offending_file(tmp_path): | ||
| """A clean file must not mask a dirty one when several are passed.""" | ||
| clean = tmp_path / "clean.json" | ||
| clean.write_text('{"a": 1}\n') | ||
| dirty = tmp_path / "dirty.json" | ||
| dirty.write_text('{"a": 1, "a": 2}\n') | ||
|
|
||
| assert guard.main([str(clean), str(dirty)]) == 1 | ||
| assert guard.main([str(dirty), str(clean)]) == 1 | ||
|
|
||
|
|
||
| def test_guard_fails_on_invalid_json(tmp_path): | ||
| path = tmp_path / "broken.json" | ||
| path.write_text("{not json") | ||
| assert guard.main([str(path)]) == 1 | ||
|
|
||
|
|
||
| def test_guard_fails_on_missing_file(tmp_path): | ||
| assert guard.main([str(tmp_path / "nope.json")]) == 1 | ||
|
|
||
|
|
||
| def test_guard_default_targets_are_the_tracked_price_maps(): | ||
| """Bare `python scripts/check_model_prices_duplicate_keys.py` must cover both.""" | ||
| assert set(guard.DEFAULT_TARGETS) == set(PRICE_MAPS) | ||
| assert guard.main([]) == 0 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a commonly repeated nested field such as
modeis duplicated in one model, this global text scan reports every"mode":line in the entire price map rather than the two occurrences in the offending object. On the current canonical map that produces roughly 20 KB of unrelated line numbers and does not identify which model contains the duplicate, making the new CI failure difficult to act on; retain the object path or source positions while parsing instead of rescanning globally by key name.Useful? React with 👍 / 👎.