llm-budget 0.2.1

Hard USD spend limits for LLM agents: per-agent and fleet caps, real per-model prices, kill switch, automatic model downgrade
Documentation
#!/usr/bin/env python3
"""Regenerate src/prices_data.rs from LiteLLM's public price table.

LiteLLM (MIT licensed, https://github.com/BerriAI/litellm) keeps a maintained
JSON file of per-token prices for hundreds of models. This script keeps the
chat models of the main first-party APIs and writes them as a sorted Rust
table, so the crate needs no network access and no JSON parsing at runtime.

Usage:
    python scripts/update_prices.py            # download the latest table
    python scripts/update_prices.py file.json  # use a local copy
"""
import datetime
import json
import os
import sys
import urllib.request

URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
PROVIDERS = {"openai", "anthropic", "gemini", "mistral", "deepseek", "xai"}
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "prices_data.rs")


def per_million(v):
    if v is None:
        return None
    return round(float(v) * 1_000_000, 6)


def rust_f64(v):
    return "f64::NAN" if v is None else repr(float(v))


def main():
    if len(sys.argv) > 1:
        with open(sys.argv[1], encoding="utf-8") as f:
            data = json.load(f)
    else:
        req = urllib.request.Request(URL, headers={"User-Agent": "llm-price-table-updater"})
        with urllib.request.urlopen(req, timeout=60) as r:
            data = json.load(r)

    rows = {}
    for key, v in data.items():
        if not isinstance(v, dict) or v.get("mode") != "chat":
            continue
        provider = v.get("litellm_provider")
        if provider not in PROVIDERS:
            continue
        name = key.split("/", 1)[1] if "/" in key else key
        if name.startswith("ft:"):
            continue
        inp, out = v.get("input_cost_per_token"), v.get("output_cost_per_token")
        if inp is None or out is None:
            continue
        if name in rows:
            continue
        max_in = v.get("max_input_tokens") or v.get("max_tokens") or 0
        try:
            max_in = int(max_in)
        except (TypeError, ValueError):
            max_in = 0
        rows[name] = (
            provider,
            per_million(inp),
            per_million(out),
            per_million(v.get("cache_read_input_token_cost")),
            per_million(v.get("cache_creation_input_token_cost")),
            max_in,
        )

    today = datetime.date.today().isoformat()
    lines = [
        "// Generated by scripts/update_prices.py from LiteLLM's",
        "// model_prices_and_context_window.json (MIT license). Do not edit by hand.",
        "// Columns: model, provider, USD per million input tokens, USD per million",
        "// output tokens, cache read and cache write per million (NaN = not listed),",
        "// max input tokens (0 = not listed). Sorted by model name.",
        "",
        "/// Date the built-in price table was generated (YYYY-MM-DD).",
        f'pub const SNAPSHOT_DATE: &str = "{today}";',
        "",
        "pub(crate) type Row = (&'static str, &'static str, f64, f64, f64, f64, u32);",
        "",
        "pub(crate) static ROWS: &[Row] = &[",
    ]
    for name in sorted(rows):
        p, i, o, cr, cw, mx = rows[name]
        lines.append(
            f"    ({json.dumps(name)}, {json.dumps(p)}, {rust_f64(i)}, {rust_f64(o)}, "
            f"{rust_f64(cr)}, {rust_f64(cw)}, {mx}),"
        )
    lines.append("];")
    with open(OUT, "w", encoding="utf-8", newline="\n") as f:
        f.write("\n".join(lines) + "\n")
    print(f"wrote {len(rows)} models to {os.path.normpath(OUT)}")


if __name__ == "__main__":
    main()