mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(cost_map): mark fireworks_ai/minimax-m3 as vision and pin capabilities verified against live calls
Fireworks' serverless minimax-m3 answers image inputs, but its model listings say supports_image_input false, so the last two registry syncs flipped supports_vision back to false and the Fireworks pre-flight turned every image request into a 400 without ever calling the provider. Flip both minimax-m3 rows back to supports_vision true and record the live-call evidence in ci_cd/cost_map_pins.json. The cost map guard now fails any PR that lowers a pinned capability, quoting the evidence, and the sync bot may not touch the pins file.
This commit is contained in:
parent
b248b1c7dc
commit
1216bd6633
5 changed files with 192 additions and 20 deletions
|
|
@ -1,11 +1,13 @@
|
|||
"""Guard the cost map on pull requests.
|
||||
|
||||
Every pull request whose diff against its merge base touches one of the three cost map files gets the file
|
||||
checks: the files parse, the backup copy matches the root file, and the JSON schema is in sync and validates the
|
||||
map. A pull request that leaves all three untouched skips them, since merging it keeps the base branch's copies
|
||||
and its head tree only carries whatever state the branch was cut from. Pull requests from the cost map sync bot
|
||||
(branches named litellm_cost_map_sync_*) always get the file checks and additionally may only touch those three
|
||||
files and may only add or update models.
|
||||
Every pull request whose diff against its merge base touches one of the three cost map files or the pins file gets
|
||||
the file checks: the files parse, the backup copy matches the root file, the JSON schema is in sync and validates the
|
||||
map, and every capability pinned in ci_cd/cost_map_pins.json (a value verified against a live provider call where the
|
||||
provider's model listing says otherwise) still holds in the map. A pull request that leaves all of them untouched
|
||||
skips the checks, since merging it keeps the base branch's copies and its head tree only carries whatever state the
|
||||
branch was cut from. Pull requests from the cost map sync bot (branches named litellm_cost_map_sync_*) always get the
|
||||
file checks and additionally may only touch the three cost map files and may only add or update models, so a sync
|
||||
can never lower a pinned capability or edit the pins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -14,7 +16,7 @@ import argparse
|
|||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -23,10 +25,13 @@ from generate_model_prices_schema import SPECIAL_ROOT_KEYS, build_schema, render
|
|||
COST_MAP_PATH: Final = "model_prices_and_context_window.json"
|
||||
BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
|
||||
SCHEMA_PATH: Final = "model_prices_and_context_window.schema.json"
|
||||
PINS_PATH: Final = "ci_cd/cost_map_pins.json"
|
||||
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH, SCHEMA_PATH)
|
||||
CHECKED_PATHS: Final = (*GUARDED_PATHS, PINS_PATH)
|
||||
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
|
||||
|
||||
CostMap = dict[str, object]
|
||||
_MISSING: Final = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -34,6 +39,16 @@ class Snapshot:
|
|||
cost_map: str
|
||||
backup: str
|
||||
schema: str
|
||||
pins: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pin:
|
||||
key: str
|
||||
field: str
|
||||
value: object
|
||||
checked: str
|
||||
source: str
|
||||
|
||||
|
||||
def _parse_object(text: str, path: str) -> CostMap | str:
|
||||
|
|
@ -82,6 +97,48 @@ def _entries(cost_map: CostMap) -> dict[str, dict[str, object]]:
|
|||
return {key: entry for key, entry in cost_map.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def _pin(key: str, field: str, spec: object) -> Pin | str:
|
||||
malformed: Final = f"{PINS_PATH}: {key}.{field} needs value, checked, and source"
|
||||
if not isinstance(spec, dict) or "value" not in spec:
|
||||
return malformed
|
||||
checked: Final = spec.get("checked")
|
||||
source: Final = spec.get("source")
|
||||
if not isinstance(checked, str) or not isinstance(source, str) or not checked or not source:
|
||||
return malformed
|
||||
return Pin(key, field, spec["value"], checked, source)
|
||||
|
||||
|
||||
def _pins(parsed: CostMap) -> Iterator[Pin | str]:
|
||||
for key, fields in parsed.items():
|
||||
if not isinstance(fields, dict):
|
||||
yield f"{PINS_PATH}: {key} must map field names to pins"
|
||||
continue
|
||||
for field, spec in fields.items():
|
||||
yield _pin(key, field, spec)
|
||||
|
||||
|
||||
def _pin_violations(parsed: CostMap, entries: dict[str, dict[str, object]]) -> Iterator[str]:
|
||||
for pin in _pins(parsed):
|
||||
if isinstance(pin, str):
|
||||
yield pin
|
||||
elif pin.key not in entries:
|
||||
yield f"{PINS_PATH} pins {pin.key}.{pin.field} but {COST_MAP_PATH} has no {pin.key} entry"
|
||||
elif entries[pin.key].get(pin.field, _MISSING) != pin.value:
|
||||
yield (
|
||||
f"{COST_MAP_PATH}: {pin.key}.{pin.field} must stay {json.dumps(pin.value)} "
|
||||
f"(verified {pin.checked}: {pin.source}); change {PINS_PATH} with new evidence first"
|
||||
)
|
||||
|
||||
|
||||
def pin_failures(pins_text: str, head_map: CostMap) -> tuple[str, ...]:
|
||||
if not pins_text:
|
||||
return ()
|
||||
parsed: Final = _parse_object(pins_text, PINS_PATH)
|
||||
if isinstance(parsed, str):
|
||||
return (parsed,)
|
||||
return tuple(_pin_violations(parsed, _entries(head_map)))
|
||||
|
||||
|
||||
def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
base_map: Final = _parse_object(base.cost_map, COST_MAP_PATH)
|
||||
if isinstance(base_map, str):
|
||||
|
|
@ -112,7 +169,7 @@ def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str
|
|||
|
||||
|
||||
def touches_cost_map(changed_files: Sequence[str]) -> bool:
|
||||
return any(path in GUARDED_PATHS for path in changed_files)
|
||||
return any(path in CHECKED_PATHS for path in changed_files)
|
||||
|
||||
|
||||
def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
||||
|
|
@ -127,7 +184,11 @@ def guard_failures(base: Snapshot, head: Snapshot, changed_files: Sequence[str],
|
|||
head_map: Final = _parse_object(head.cost_map, COST_MAP_PATH)
|
||||
if isinstance(head_map, str):
|
||||
return (head_map,)
|
||||
return (*_file_failures(head, head_map), *(_bot_failures(base, head_map, changed_files) if bot else ()))
|
||||
return (
|
||||
*_file_failures(head, head_map),
|
||||
*pin_failures(head.pins, head_map),
|
||||
*(_bot_failures(base, head_map, changed_files) if bot else ()),
|
||||
)
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
|
|
@ -136,7 +197,7 @@ def _git(*args: str) -> str | None:
|
|||
|
||||
|
||||
def snapshot(revision: str) -> Snapshot:
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in GUARDED_PATHS))
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in CHECKED_PATHS))
|
||||
|
||||
|
||||
def changed_files(base: str, head: str) -> tuple[str, ...] | None:
|
||||
|
|
|
|||
16
ci_cd/cost_map_pins.json
Normal file
16
ci_cd/cost_map_pins.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"fireworks_ai/accounts/fireworks/models/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
},
|
||||
"fireworks_ai/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25828,7 +25828,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
|
|
@ -26119,7 +26119,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/qwen3p7-plus": {
|
||||
"cache_read_input_token_cost": 8e-08,
|
||||
|
|
|
|||
|
|
@ -25828,7 +25828,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
|
|
@ -26119,7 +26119,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/qwen3p7-plus": {
|
||||
"cache_read_input_token_cost": 8e-08,
|
||||
|
|
|
|||
|
|
@ -51,11 +51,16 @@ def _serialize(cost_map: dict[str, object]) -> str:
|
|||
return json.dumps(cost_map, indent=4, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _snapshot(cost_map: dict[str, object], backup: str | None = None, schema: str | None = None) -> object:
|
||||
def _snapshot(
|
||||
cost_map: dict[str, object], backup: str | None = None, schema: str | None = None, pins: str = ""
|
||||
) -> object:
|
||||
text = _serialize(cost_map)
|
||||
rendered = schema_module.render(schema_module.build_schema(cost_map))
|
||||
return guard.Snapshot(
|
||||
cost_map=text, backup=text if backup is None else backup, schema=rendered if schema is None else schema
|
||||
cost_map=text,
|
||||
backup=text if backup is None else backup,
|
||||
schema=rendered if schema is None else schema,
|
||||
pins=pins,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -129,9 +134,9 @@ def test_human_pr_that_leaves_the_cost_map_alone_skips_the_file_checks() -> None
|
|||
assert _failures(unparseable, changed_files=CODE_ONLY, bot=False) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guarded_path", guard.GUARDED_PATHS)
|
||||
def test_touching_any_cost_map_file_keeps_the_file_checks(guarded_path: str) -> None:
|
||||
failures: Final = _failures(STALE_HEAD, changed_files=(*CODE_ONLY, guarded_path), bot=False)
|
||||
@pytest.mark.parametrize("checked_path", guard.CHECKED_PATHS)
|
||||
def test_touching_any_cost_map_file_keeps_the_file_checks(checked_path: str) -> None:
|
||||
failures: Final = _failures(STALE_HEAD, changed_files=(*CODE_ONLY, checked_path), bot=False)
|
||||
assert [failure for failure in failures if failure.startswith(guard.BACKUP_PATH)]
|
||||
assert [failure for failure in failures if failure.startswith(guard.SCHEMA_PATH)]
|
||||
|
||||
|
|
@ -175,8 +180,76 @@ def test_bot_may_not_change_special_root_keys() -> None:
|
|||
assert _failures(head) == ("bot PRs may not change fallback_generalizations",)
|
||||
|
||||
|
||||
def _commit(repo: Path, cost_map: dict[str, object], message: str) -> str:
|
||||
PINNED_SOURCE: Final = "a 512x512 probe image was read back over the provider's chat endpoint"
|
||||
PINS: Final = _serialize(
|
||||
{"openrouter/a": {"supports_vision": {"value": True, "checked": "2026-09-26", "source": PINNED_SOURCE}}}
|
||||
)
|
||||
|
||||
|
||||
def test_a_pinned_capability_that_holds_passes_for_humans_and_bots() -> None:
|
||||
head = _snapshot(BASE_MAP, pins=PINS)
|
||||
assert _failures(head, bot=False) == ()
|
||||
assert _failures(head, bot=True) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bot", [False, True])
|
||||
def test_lowering_a_pinned_capability_fails_with_its_evidence(bot: bool) -> None:
|
||||
head = _snapshot({**BASE_MAP, "openrouter/a": _entry(supports_vision=False)}, pins=PINS)
|
||||
assert _failures(head, bot=bot) == (
|
||||
f"{guard.COST_MAP_PATH}: openrouter/a.supports_vision must stay true (verified 2026-09-26: {PINNED_SOURCE}); "
|
||||
f"change {guard.PINS_PATH} with new evidence first",
|
||||
)
|
||||
|
||||
|
||||
def test_dropping_a_pinned_field_or_model_is_reported() -> None:
|
||||
no_field = _snapshot({**BASE_MAP, "openrouter/a": _entry()}, pins=PINS)
|
||||
assert [failure for failure in _failures(no_field, bot=False) if "must stay true" in failure]
|
||||
no_model = _snapshot({key: value for key, value in BASE_MAP.items() if key != "openrouter/a"}, pins=PINS)
|
||||
assert _failures(no_model, bot=False) == (
|
||||
f"{guard.PINS_PATH} pins openrouter/a.supports_vision but {guard.COST_MAP_PATH} has no openrouter/a entry",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pins", "expected"),
|
||||
[
|
||||
(
|
||||
"{not json",
|
||||
f"{guard.PINS_PATH} is not valid JSON: Expecting property name enclosed in double quotes: "
|
||||
"line 1 column 2 (char 1)",
|
||||
),
|
||||
(
|
||||
_serialize({"openrouter/a": {"supports_vision": {"value": True}}}),
|
||||
f"{guard.PINS_PATH}: openrouter/a.supports_vision needs value, checked, and source",
|
||||
),
|
||||
(_serialize({"openrouter/a": True}), f"{guard.PINS_PATH}: openrouter/a must map field names to pins"),
|
||||
],
|
||||
)
|
||||
def test_malformed_pins_are_reported(pins: str, expected: str) -> None:
|
||||
assert _failures(_snapshot(BASE_MAP, pins=pins), bot=False) == (expected,)
|
||||
|
||||
|
||||
def test_bot_may_not_touch_the_pins_file() -> None:
|
||||
head = _snapshot(BASE_MAP, pins=PINS)
|
||||
changed = (*guard.GUARDED_PATHS, guard.PINS_PATH)
|
||||
assert _failures(head, changed_files=changed, bot=False) == ()
|
||||
assert _failures(head, changed_files=changed) == (
|
||||
f"bot PRs may only change the cost map files, not {guard.PINS_PATH}",
|
||||
)
|
||||
|
||||
|
||||
def test_checked_in_pins_hold_in_the_checked_in_cost_map() -> None:
|
||||
cost_map = json.loads((ROOT / guard.COST_MAP_PATH).read_text())
|
||||
pins = (ROOT / guard.PINS_PATH).read_text()
|
||||
assert json.loads(pins), "the pins file must pin at least one capability"
|
||||
assert guard.pin_failures(pins, cost_map) == ()
|
||||
|
||||
|
||||
def _commit(repo: Path, cost_map: dict[str, object], message: str, pins: str | None = None) -> str:
|
||||
text = _serialize(cost_map)
|
||||
if pins is not None:
|
||||
(repo / guard.PINS_PATH).parent.mkdir(exist_ok=True)
|
||||
(repo / guard.PINS_PATH).write_text(pins)
|
||||
(repo / guard.COST_MAP_PATH).write_text(text)
|
||||
(repo / guard.BACKUP_PATH).parent.mkdir(exist_ok=True)
|
||||
(repo / guard.BACKUP_PATH).write_text(text)
|
||||
|
|
@ -271,3 +344,25 @@ def test_main_rejects_a_bot_pr_that_edits_code(tmp_path: Path) -> None:
|
|||
head = _commit(tmp_path, {**BASE_MAP, "openrouter/c": _entry()}, "head")
|
||||
assert _run_guard(tmp_path, base, head, BOT_REF).returncode == 1
|
||||
assert _run_guard(tmp_path, base, head, "litellm_fix_pricing").returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head_ref", [BOT_REF, "litellm_fix_pricing"])
|
||||
def test_main_reads_the_pins_from_the_head_revision(tmp_path: Path, head_ref: str) -> None:
|
||||
subprocess.run(("git", "init", "-q", str(tmp_path)), check=True)
|
||||
base: Final = _commit(tmp_path, BASE_MAP, "base", pins=PINS)
|
||||
head: Final = _commit(tmp_path, {**BASE_MAP, "openrouter/a": _entry(supports_vision=False)}, "sync lowers it")
|
||||
result: Final = _run_guard(tmp_path, base, head, head_ref)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
pin_line: Final = f"- {guard.COST_MAP_PATH}: openrouter/a.supports_vision must stay true (verified 2026-09-26: "
|
||||
assert [line for line in result.stdout.splitlines() if line.startswith(pin_line)]
|
||||
|
||||
|
||||
def test_main_checks_a_pins_only_pr_against_the_head_map(tmp_path: Path) -> None:
|
||||
subprocess.run(("git", "init", "-q", str(tmp_path)), check=True)
|
||||
base: Final = _commit(tmp_path, {**BASE_MAP, "openrouter/a": _entry(supports_vision=False)}, "base")
|
||||
(tmp_path / guard.PINS_PATH).parent.mkdir(exist_ok=True)
|
||||
(tmp_path / guard.PINS_PATH).write_text(PINS)
|
||||
head: Final = _git_commit(tmp_path, "pin a capability the map disagrees with")
|
||||
result: Final = _run_guard(tmp_path, base, head, "litellm_fix_pricing")
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "cost map guard failed (human PR, file checks only):" in result.stdout.splitlines()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue