feat(budgets): support annual calendar reset periods (#51508)

* feat(budgets): support annual calendar reset periods

* fix(budgets): derive annual spend windows from calendar boundaries

* fix(budgets): share timezone-aware calendar window boundaries

* fix: preserve budget accounting across calendar boundaries

* fix(budgets): isolate calendar changes to annual budgets
This commit is contained in:
tin-berri 2026-11-12 19:40:40 -08:00 • committed by GitHub
parent b60246c352
commit f4f94cc848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 322 additions and 26 deletions

View file

@ -167,6 +167,15 @@ def _shift_months(moment: datetime, months: int) -> datetime:
return moment.replace(year=year, month=month, day=min(moment.day, get_last_day_of_month(year, month)))
def is_annual_budget_duration(duration: str) -> bool:
return _parse_duration(normalize_duration(duration)) == (12, "mo")
def get_annual_budget_window_start(reset_at: datetime, timezone_str: str) -> datetime:
boundary: Final = _setup_timezone(reset_at, timezone_str)[0]
return _shift_months(boundary, -12).astimezone(timezone.utc)
def get_budget_window_start(duration: str, reset_at: datetime) -> datetime:
"""Start of the budget period that ends at `reset_at`, under the same rules
`get_next_standardized_reset_time` used to pick it: `30d` and `Nmo` span calendar
@ -390,14 +399,22 @@ def _handle_month_reset(
Args:
current_time: Current datetime
base_midnight: Midnight of current day
value: Number of months (currently only supports 1 month resets)
value: Number of months, 1 for monthly or 12 for annual resets
reset_time_of_day: Wall-clock time the reset lands on
Returns:
datetime: First day of the next reset month at `reset_time_of_day`
"""
if value == 12:
first_of_year: Final = base_midnight.replace(month=1, day=1)
annual_candidate: Final = _apply_time_of_day(first_of_year, reset_time_of_day)
return (
annual_candidate.replace(year=annual_candidate.year + 1)
if annual_candidate <= current_time
else annual_candidate
)
if value != 1:
raise ValueError("Monthly resets currently only support 1 month intervals")
raise ValueError("Calendar resets support 1 month or 12 month intervals")
first_of_this_month: Final = base_midnight.replace(day=1)
candidate: Final = _apply_time_of_day(first_of_this_month, reset_time_of_day)

View file

@ -30,7 +30,11 @@ from litellm.constants import (
RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN,
RESET_BUDGET_JOB_NAME,
)
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.duration_parser import (
duration_in_seconds,
get_annual_budget_window_start,
is_annual_budget_duration,
)
from litellm.proxy._types import (
DB_RETRY_SAFE_ERROR_TYPES,
LiteLLM_BudgetTableFull,
@ -1514,16 +1518,21 @@ class ResetBudgetJob:
reset_at_str: Final = window.get("reset_at")
if not reset_at_str:
return False
reset_at: Final = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")).replace(tzinfo=None)
if reset_at > now:
budget_duration: Final = window["budget_duration"]
annual: Final = isinstance(budget_duration, str) and is_annual_budget_duration(budget_duration)
saved_reset_at: Final = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00"))
reset_at: Final = _as_utc(saved_reset_at) if annual else saved_reset_at.replace(tzinfo=None)
cutoff: Final = _as_utc(now) if annual else now
if reset_at > cutoff:
return False
new_value: Final = await ResetBudgetJob._window_carried_spend(window, counter_key, spend_counter_store)
try:
await spend_counter_store.set(counter_key, new_value)
except Exception as redis_err:
verbose_proxy_logger.warning("Failed to reset Redis counter %s: %s", counter_key, redis_err)
budget_duration: Final = window["budget_duration"]
next_reset_at: Final = compute_budget_reset_at(budget_duration=budget_duration, settings=reset_settings)
next_reset_at: Final = compute_budget_reset_at(
budget_duration=budget_duration, settings=reset_settings, now=now if annual else None
)
window["reset_at"] = next_reset_at.isoformat()
await ResetBudgetJob._roll_window_spend_row(
prisma_client=prisma_client,
@ -1531,6 +1540,7 @@ class ResetBudgetJob:
entity_id=entity_id,
budget_duration=budget_duration,
next_reset_at=next_reset_at,
reset_settings=reset_settings,
)
return True
@ -1541,6 +1551,7 @@ class ResetBudgetJob:
entity_id: str,
budget_duration: str,
next_reset_at: datetime,
reset_settings: BudgetResetSettings | None = None,
) -> None:
"""Move this window's LiteLLM_BudgetWindowSpend row onto the window
that just started, so the maintained total the read path uses starts
@ -1551,7 +1562,11 @@ class ResetBudgetJob:
windows from having their counters reset.
"""
try:
window_start: Final = next_reset_at - timedelta(seconds=duration_in_seconds(budget_duration))
window_start: Final = (
get_annual_budget_window_start(next_reset_at, (reset_settings or get_budget_reset_settings()).timezone)
if is_annual_budget_duration(budget_duration)
else next_reset_at - timedelta(seconds=duration_in_seconds(budget_duration))
)
except Exception as e: # noqa: BLE001 # duration_in_seconds raises bare exceptions on bad input
verbose_proxy_logger.warning("Unparseable budget_duration %s: %s", budget_duration, e)
return
@ -1708,7 +1723,9 @@ class ResetBudgetJob:
item.spend = _carried_spend(item.spend, _rollover_cap(item.max_budget)) if _rollover_enabled() else 0.0
if hasattr(item, "budget_duration") and item.budget_duration is not None:
item.budget_reset_at = compute_budget_reset_at(
budget_duration=item.budget_duration, settings=reset_settings
budget_duration=item.budget_duration,
settings=reset_settings,
now=current_time if is_annual_budget_duration(item.budget_duration) else None,
)
return item
except Exception as e:

View file

@ -63,11 +63,13 @@ def get_budget_reset_settings() -> BudgetResetSettings:
)
def compute_budget_reset_at(budget_duration: str, settings: BudgetResetSettings) -> datetime:
def compute_budget_reset_at(
budget_duration: str, settings: BudgetResetSettings, now: datetime | None = None
) -> datetime:
"""Compute the next reset time for a budget duration using injected settings."""
return get_next_standardized_reset_time(
duration=budget_duration,
current_time=datetime.now(timezone.utc),
current_time=now if now is not None else datetime.now(timezone.utc),
timezone_str=settings.timezone,
reset_time_of_day=settings.reset_time_of_day,
)

View file

@ -18,7 +18,11 @@ import litellm
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.duration_parser import (
duration_in_seconds,
get_annual_budget_window_start,
is_annual_budget_duration,
)
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate
from litellm.proxy._types import (
Litellm_EntityType,
@ -30,6 +34,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_utils import get_model_from_request
from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.timezone_utils import BudgetResetSettings, get_budget_reset_settings
from litellm.proxy.common_utils.user_api_key_cache import (
AUTH_OBJECTS_TARGET,
UserApiKeyCache,
@ -1287,12 +1292,18 @@ def fixed_budget_window(window: object) -> tuple[str, datetime] | None:
return duration, start
def get_budget_window_start(window: object) -> datetime | None:
def get_budget_window_start(window: object, reset_settings: BudgetResetSettings | None = None) -> datetime | None:
window_dict: Final = _coerce_window(window)
budget_duration: Final = window_dict.get("budget_duration")
if budget_duration is None:
return None
try:
if is_annual_budget_duration(str(budget_duration)):
annual_reset_at: Final = _coerce_datetime(window_dict.get("reset_at"))
if annual_reset_at is not None:
return get_annual_budget_window_start(
annual_reset_at, (reset_settings or get_budget_reset_settings()).timezone
)
duration_seconds: Final = duration_in_seconds(str(budget_duration))
except Exception:
return None

View file

@ -175,7 +175,11 @@ from litellm.litellm_core_utils.core_helpers import (
require_attributes,
)
from litellm.litellm_core_utils.deployment_pricing import pricing_models
from litellm.litellm_core_utils.duration_parser import get_budget_window_start
from litellm.litellm_core_utils.duration_parser import (
get_annual_budget_window_start,
get_budget_window_start,
is_annual_budget_duration,
)
from litellm.litellm_core_utils.env_utils import get_env_float, get_env_int
from litellm.litellm_core_utils.litellm_logging import (
BilledRoundsDispatch,
@ -208,6 +212,7 @@ from litellm.proxy.agent_endpoints.auth.agent_access_groups import CeilingResolv
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.callback_utils import add_guardrail_to_applied_guardrails_header
from litellm.proxy.common_utils.config_sync_pubsub import publish_config_param_change
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_settings
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.db import tool_registry_writer
from litellm.proxy.db.create_views import (
@ -9535,7 +9540,11 @@ def _as_aware(moment: datetime) -> datetime:
def _reset_window(budget_duration: str, budget_reset_at: datetime) -> tuple[datetime, datetime] | None:
window_end: Final = _as_aware(budget_reset_at)
window_start: Final = get_budget_window_start(budget_duration, window_end)
window_start: Final = (
get_annual_budget_window_start(window_end, get_budget_reset_settings().timezone)
if is_annual_budget_duration(budget_duration)
else get_budget_window_start(budget_duration, window_end)
)
return (window_start, window_end) if window_start < window_end else None

View file

@ -47,6 +47,7 @@ const RESET_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: "24h", labelKey: "budgets.form.resetOptions.perDay" },
{ value: "7d", labelKey: "budgets.form.resetOptions.perWeek" },
{ value: "30d", labelKey: "budgets.form.resetOptions.perMonth" },
{ value: "12mo", labelKey: "budgets.form.resetOptions.perYear" },
];
const numberProps = <T extends { value?: number | null; onChange: (value: number | null) => void }>({

View file

@ -25,7 +25,10 @@ describe("BudgetModal", () => {
createMock.mockResolvedValue(undefined);
});
it("submits the spend limit and reset period alongside the rate limits", async () => {
it.each([
["per month", "30d"],
["per year", "12mo"],
])("submits the spend limit %s alongside the rate limits", async (label, duration) => {
const user = userEvent.setup();
renderModal();
@ -33,7 +36,7 @@ describe("BudgetModal", () => {
expect(resetPeriod()).toBeVisible();
fireEvent.change(screen.getByLabelText("Budget ID"), { target: { value: "budget-alpha" } });
fireEvent.change(screen.getByLabelText(/^Spend limit/), { target: { value: "42.567" } });
await chooseSelectOption(user, resetPeriod(), "per month");
await chooseSelectOption(user, resetPeriod(), label);
fireEvent.change(screen.getByLabelText("Tokens per minute"), { target: { value: "500" } });
fireEvent.change(screen.getByLabelText("Requests per minute"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("Batch tokens per day"), { target: { value: "90000" } });
@ -43,7 +46,7 @@ describe("BudgetModal", () => {
const expected = {
budget_id: "budget-alpha",
max_budget: 42.57,
budget_duration: "30d",
budget_duration: duration,
tpm_limit: 500,
rpm_limit: 7,
tpd_limit: 90000,

View file

@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import BudgetDurationDropdown from "./budget_duration_dropdown";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./budget_duration_dropdown";
describe("BudgetDurationDropdown", () => {
it("labels an unset period as Never resets in the trigger and option", async () => {
@ -39,3 +39,13 @@ describe("BudgetDurationDropdown", () => {
expect(await screen.findByRole("option", { name: "Not set" })).toBeInTheDocument();
});
});
it("saves and displays an annual budget period", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<BudgetDurationDropdown value="30d" onChange={onChange} />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByRole("option", { name: "annually" }));
expect(onChange).toHaveBeenCalledWith("12mo", expect.anything());
expect(getBudgetDurationLabel("12mo")).toBe("annually");
});

View file

@ -12,6 +12,7 @@ const DURATION_LABELS: Record<string, ParseKeys> = {
"24h": "budgetDurationDropdown.periods.daily",
"7d": "budgetDurationDropdown.periods.weekly",
"30d": "budgetDurationDropdown.periods.monthly",
"12mo": "budgetDurationDropdown.periods.annual",
};
interface BudgetDurationDropdownProps {
@ -51,6 +52,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
<SelectItem value="24h">{t("budgetDurationDropdown.periods.daily")}</SelectItem>
<SelectItem value="7d">{t("budgetDurationDropdown.periods.weekly")}</SelectItem>
<SelectItem value="30d">{t("budgetDurationDropdown.periods.monthly")}</SelectItem>
<SelectItem value="12mo">{t("budgetDurationDropdown.periods.annual")}</SelectItem>
</SelectContent>
</Select>
);
@ -64,6 +66,7 @@ export const getBudgetDurationLabel = (value: string | null | undefined, t: TFun
"24h": "budgetDurationDropdown.periods.daily",
"7d": "budgetDurationDropdown.periods.weekly",
"30d": "budgetDurationDropdown.periods.monthly",
"12mo": "budgetDurationDropdown.periods.annual",
};
return Object.hasOwn(budgetDurationMap, value) ? t(budgetDurationMap[value]) : value;

View file

@ -3072,7 +3072,8 @@
"hourly": "hourly",
"daily": "daily",
"weekly": "weekly",
"monthly": "monthly"
"monthly": "monthly",
"annual": "annually"
},
"notSet": "Not set"
},
@ -3406,7 +3407,8 @@
"inTotal": "in total",
"perDay": "per day",
"perWeek": "per week",
"perMonth": "per month"
"perMonth": "per month",
"perYear": "per year"
},
"noLimit": "No limit",
"optional": "Optional",

View file

@ -10,6 +10,7 @@ import litellm.litellm_core_utils.duration_parser as duration_parser
from litellm.litellm_core_utils.duration_parser import (
duration_in_seconds,
duration_in_seconds_from,
get_annual_budget_window_start,
get_budget_window_start,
get_next_standardized_reset_time,
)
@ -195,6 +196,79 @@ class TestStandardizedResetTime(unittest.TestCase):
self.assertEqual(result, expected)
@pytest.mark.parametrize(
"now, zone, reset_time, expected",
[
(
datetime(2026, 11, 12, tzinfo=timezone.utc),
"UTC",
time(0),
datetime(2027, 1, 1, tzinfo=timezone.utc),
),
(
datetime(2028, 2, 29, tzinfo=timezone.utc),
"UTC",
time(0),
datetime(2029, 1, 1, tzinfo=timezone.utc),
),
(
datetime(2027, 1, 1, tzinfo=timezone.utc),
"UTC",
time(0),
datetime(2028, 1, 1, tzinfo=timezone.utc),
),
(
datetime(2027, 1, 1, 8, tzinfo=timezone.utc),
"UTC",
time(12),
datetime(2027, 1, 1, 12, tzinfo=timezone.utc),
),
(
datetime(2027, 1, 1, 12, tzinfo=timezone.utc),
"UTC",
time(12),
datetime(2028, 1, 1, 12, tzinfo=timezone.utc),
),
(
datetime(2027, 1, 1, 2, tzinfo=timezone.utc),
"America/New_York",
time(0),
datetime(2027, 1, 1, tzinfo=ZoneInfo("America/New_York")),
),
],
)
def test_annual_reset_and_window_start(
now: datetime, zone: str, reset_time: time, expected: datetime
) -> None:
reset: Final = get_next_standardized_reset_time("12mo", now, zone, reset_time)
assert reset == expected
assert get_budget_window_start("12mo", reset) == expected.replace(year=expected.year - 1)
@pytest.mark.parametrize(
"zone,reset_at,expected",
(
("UTC", "2029-01-01T00:00:00+00:00", "2028-01-01T00:00:00+00:00"),
("UTC", "2101-01-01T00:00:00+00:00", "2100-01-01T00:00:00+00:00"),
("America/New_York", "2029-01-01T00:00:00-05:00", "2028-01-01T05:00:00+00:00"),
("Asia/Kathmandu", "2029-01-01T12:30:00+05:45", "2028-01-01T06:45:00+00:00"),
("Pacific/Apia", "2012-01-01T00:00:00+14:00", "2011-01-01T10:00:00+00:00"),
),
)
@pytest.mark.parametrize("storage", ("offset", "utc", "naive_utc"))
def test_annual_window_survives_timestamp_storage(
zone: str, reset_at: str, expected: str, storage: str
) -> None:
reset: Final = datetime.fromisoformat(reset_at)
stored: Final = {
"offset": reset,
"utc": reset.astimezone(timezone.utc),
"naive_utc": reset.astimezone(timezone.utc).replace(tzinfo=None),
}[storage]
assert get_annual_budget_window_start(stored, zone) == datetime.fromisoformat(expected)
class TestResetTimeOfDay(unittest.TestCase):
"""A configurable reset_time_of_day shifts day/week/month resets off midnight."""

View file

@ -28,11 +28,71 @@ from litellm.proxy.common_utils.reset_budget_job import (
_write_team_windows,
)
from litellm.proxy.common_utils.timezone_utils import BudgetResetSettings
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.utils import PrismaClient
from tests.unit.proxy.db.fake_prisma_engine import engine_call
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
@pytest.mark.asyncio
@pytest.mark.parametrize(
"reset_at,now",
(
("2027-01-01T00:00:00-05:00", "2027-01-01T04:59:59+00:00"),
("2027-01-01T00:00:00+05:45", "2026-12-31T18:14:59+00:00"),
),
)
async def test_annual_window_reset_waits_for_the_saved_instant(reset_at: str, now: str) -> None:
store: Final = FakeSpendCounters().store()
counter: Final = "spend:key:offset-budget:window:12mo"
await store.set(counter, 10.0)
window: Final = {"budget_duration": "12mo", "reset_at": reset_at, "max_budget": 10.0}
client: Final = MagicMock(spec=PrismaClient)
changed: Final = await ResetBudgetJob._reset_expired_window(
window,
counter,
store,
datetime.fromisoformat(now),
BudgetResetSettings(timezone="America/New_York"),
client,
reset_budget_job_module.Litellm_EntityType.KEY,
"offset-budget",
)
assert changed is False
assert (await store.read(counter))[0] == 10.0
assert window["reset_at"] == reset_at
@pytest.mark.asyncio
@pytest.mark.parametrize("today", ["2028-03-01", "2029-03-01"])
async def test_annual_reset_persists_the_same_calendar_start_as_admission(today: str) -> None: # test-quality-ok: the SQL write is the reset job's observable persistence contract
client: Final = MagicMock(spec=PrismaClient)
client.db = MagicMock()
client.db.execute_raw = AsyncMock(return_value=1)
with patch("time.time", return_value=datetime.fromisoformat(today).replace(tzinfo=timezone.utc).timestamp()):
await ResetBudgetJob._roll_window_spend_row(
prisma_client=client,
entity_type=reset_budget_job_module.Litellm_EntityType.KEY,
entity_id="annual-key",
budget_duration="12mo",
next_reset_at=datetime(2029, 1, 1, tzinfo=timezone.utc),
reset_settings=BudgetResetSettings(),
)
assert client.db.execute_raw.await_count == 1
assert client.db.execute_raw.await_args.args[1:5] == (
"key",
"annual-key",
"12mo",
datetime(2028, 1, 1),
)
assert get_budget_window_start(
{"budget_duration": "12mo", "reset_at": datetime(2029, 1, 1, tzinfo=timezone.utc)}
) == client.db.execute_raw.await_args.args[4].replace(tzinfo=timezone.utc)
# Mock classes for testing
class MockTable:
"""A single prisma table: records reads/writes and replays canned rows."""

View file

@ -2,7 +2,9 @@ from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final
from unittest.mock import patch
import pytest
@ -23,6 +25,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
project_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.common_utils.timezone_utils import BudgetResetSettings
from litellm.proxy.spend_tracking.budget_reservation import (
_get_team_member_budget_counter,
estimate_request_max_cost,
@ -465,6 +468,88 @@ def test_budget_window_start_reads_json_encoded_windows(window: str, expected: d
assert get_budget_window_start(window) == expected
@pytest.mark.parametrize("today", ["2028-03-01", "2029-03-01"])
@pytest.mark.parametrize(
("duration", "reset_at", "expected"),
[
("12mo", "2029-01-01T00:00:00Z", datetime(2028, 1, 1, tzinfo=timezone.utc)),
("12mo", "2028-01-01T00:00:00", datetime(2027, 1, 1, tzinfo=timezone.utc)),
],
)
def test_annual_window_keeps_all_spend_from_its_reset_boundary(
today: str, duration: str, reset_at: str, expected: datetime
) -> None:
with patch("time.time", return_value=datetime.fromisoformat(today).replace(tzinfo=timezone.utc).timestamp()):
assert get_budget_window_start({"budget_duration": duration, "reset_at": reset_at}) == expected
class _AnnualWindowDatabase:
def __init__(self) -> None:
self.db = self
self.litellm_budgetwindowspend = self
self.litellm_spendlogs = self
self.start: datetime | None = None
self.spend = 0.0
self.scans = 0
async def execute_raw(self, sql: str, *parameters: object) -> int:
start: Final = parameters[3]
assert isinstance(start, datetime)
self.start = start
self.spend = 0.0
return 1
async def find_unique(self, **query: object) -> SimpleNamespace:
return SimpleNamespace(window_start=self.start, spend=self.spend)
async def group_by(self, **query: object) -> list[dict[str, object]]:
self.scans += 1
return [{"api_key": "calendar-key", "_sum": {"spend": 0.0}}]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"duration,zone,reset_at",
(
("12mo", "UTC", "2029-01-01T00:00:00Z"),
("12mo", "America/New_York", "2029-01-01T00:00:00-05:00"),
("12mo", "Pacific/Apia", "2012-01-01T00:00:00+14:00"),
),
)
async def test_cold_annual_counter_preserves_reset_writer_spend(
monkeypatch: pytest.MonkeyPatch, duration: str, zone: str, reset_at: str
) -> None:
from litellm.proxy.auth.auth_checks import _virtual_key_multi_budget_check
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
from litellm.types.integrations.slack_alerting import Litellm_EntityType
database: Final = _AnnualWindowDatabase()
store: Final = SpendCounterStore(connect(None).spend_counters())
monkeypatch.setattr(litellm, "timezone", zone, raising=False)
monkeypatch.setattr(proxy_server, "prisma_client", database)
monkeypatch.setattr(proxy_server, "spend_counter_store", store)
monkeypatch.setattr(proxy_server, "spend_counter_cache", DualCache())
await ResetBudgetJob._roll_window_spend_row(
prisma_client=database, # pyright: ignore[reportArgumentType] # in-memory external database boundary
entity_type=Litellm_EntityType.KEY,
entity_id="calendar-key",
budget_duration=duration,
next_reset_at=datetime.fromisoformat(reset_at.replace("Z", "+00:00")),
reset_settings=BudgetResetSettings(timezone=zone),
)
database.spend = 10.0
token: Final = UserAPIKeyAuth(
token="calendar-key",
budget_limits=[{"budget_duration": duration, "reset_at": reset_at, "max_budget": 10.0}],
)
with pytest.raises(litellm.BudgetExceededError) as failure:
await _virtual_key_multi_budget_check(token)
assert failure.value.current_cost == 10.0
assert store.get_local(f"spend:key:calendar-key:window:{duration}") == 10.0
assert database.scans == 0
@pytest.mark.asyncio
async def test_reservation_counts_a_pdf_with_the_deployment_behind_a_gpt_named_group() -> None:
from litellm.proxy.spend_tracking.input_tokens import count_input_tokens_for_model

View file

@ -13933,12 +13933,13 @@ async def _drain(queue):
@pytest.mark.asyncio
async def test_key_window_spend_row_is_enqueued_with_the_actual_cost():
@pytest.mark.parametrize("duration", ("30d", "12mo"))
async def test_key_window_spend_row_is_enqueued_with_the_actual_cost(duration: str):
from litellm.proxy.proxy_server import increment_spend_counters
reset_at = datetime.now(timezone.utc) + timedelta(days=10)
reset_at: Final = datetime(2029, 1, 1, tzinfo=timezone.utc)
key_obj = MagicMock()
key_obj.budget_limits = [{"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()}]
key_obj.budget_limits = [{"budget_duration": duration, "max_budget": 100.0, "reset_at": reset_at.isoformat()}]
with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue:
await increment_spend_counters(token="hashed-token", team_id=None, user_id=None, response_cost=0.25)
@ -13947,9 +13948,10 @@ async def test_key_window_spend_row_is_enqueued_with_the_actual_cost():
assert len(enqueued) == 1
assert enqueued[0]["entity_type"] == "key"
assert enqueued[0]["entity_id"] == "hashed-token"
assert enqueued[0]["window_duration"] == "30d"
assert enqueued[0]["window_duration"] == duration
assert enqueued[0]["spend"] == pytest.approx(0.25)
assert enqueued[0]["window_start"] == (reset_at - timedelta(days=30)).astimezone(timezone.utc).replace(
expected_start: Final = datetime(2028, 1, 1, tzinfo=timezone.utc) if duration == "12mo" else reset_at - timedelta(days=30)
assert enqueued[0]["window_start"] == expected_start.astimezone(timezone.utc).replace(
tzinfo=None
).isoformat(timespec="microseconds")