From 5741ec7cb5a49f6329a9cb0e73771a0d206003c0 Mon Sep 17 00:00:00 2001 From: kigland Date: Sat, 30 May 2026 14:31:29 +0800 Subject: [PATCH 1/5] fix: reject malformed duration strings instead of silently misparsing _extract_from_regex used re.match, which only anchors the start of the string, so trailing characters after a valid unit were silently dropped -- e.g. "10mb" parsed as 10 minutes and "5days" as 5 days. Use re.fullmatch so the whole string must be a valid "" duration, otherwise raise. --- litellm/litellm_core_utils/duration_parser.py | 4 +++- .../litellm_core_utils/test_duration_parser.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 5cc75b7dac2..34aac91977d 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -27,7 +27,9 @@ def _normalize_duration(duration: str) -> str: def _extract_from_regex(duration: str) -> tuple[int, str]: - match = re.match(r"(\d+)(mo|[smhdw]?)", duration) + # Use fullmatch so trailing characters after a valid unit are rejected + # rather than silently dropped (e.g. "10mb" must not parse as 10 minutes). + match = re.fullmatch(r"(\d+)(mo|[smhdw]?)", duration) if not match: raise ValueError("Invalid duration format") diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index cb9f273a0a7..575dd77f5e5 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -385,5 +385,20 @@ class TestWordFormBudgetDurations(unittest.TestCase): self.assertIn("garbage", mock_warning.call_args.args) +class TestDurationInSeconds(unittest.TestCase): + def test_valid_durations(self): + self.assertEqual(duration_in_seconds("30s"), 30) + self.assertEqual(duration_in_seconds("10m"), 600) + self.assertEqual(duration_in_seconds("2h"), 7200) + self.assertEqual(duration_in_seconds("3d"), 259200) + + def test_malformed_durations_raise(self): + # Trailing characters after a valid unit must be rejected, not silently + # dropped: "10mb" previously parsed as 10 minutes. + for bad in ["10mb", "5days", "30x", "abc", "10 m"]: + with self.assertRaises(ValueError): + duration_in_seconds(bad) + + if __name__ == "__main__": unittest.main() From f8a7a3086b9b30708e2a34f880880f3ff9cbe7c1 Mon Sep 17 00:00:00 2001 From: kigland Date: Sat, 30 May 2026 17:34:37 +0800 Subject: [PATCH 2/5] fix duration parser full string matching --- litellm/litellm_core_utils/duration_parser.py | 2 +- .../litellm_core_utils/test_duration_parser.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 34aac91977d..75844495c30 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -193,7 +193,7 @@ def _setup_timezone(current_time: datetime, timezone_str: str = "UTC") -> tuple[ def _parse_duration(duration: str) -> tuple[int | None, str | None]: """Parse the duration string into value and unit.""" - match = re.match(r"(\d+)([a-z]+)", duration) + match = re.fullmatch(r"(\d+)(mo|[smhdw])", duration) if not match: return None, None diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index 575dd77f5e5..f082df78deb 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -149,6 +149,14 @@ class TestStandardizedResetTime(unittest.TestCase): ) self.assertEqual(invalid_tz_result, invalid_tz_expected) + def test_malformed_duration_with_trailing_text_falls_back(self): + base_time = datetime(2023, 5, 15, 15, 20, 30, tzinfo=timezone.utc) + expected = datetime(2023, 5, 16, 0, 0, 0, tzinfo=timezone.utc) + + result = get_next_standardized_reset_time("10m!", base_time, "UTC") + + self.assertEqual(result, expected) + def test_iana_timezones_previously_unsupported(self): """Test IANA timezones that were previously unsupported by the hardcoded map.""" # Base time: 2023-05-15 15:00:00 UTC @@ -391,6 +399,8 @@ class TestDurationInSeconds(unittest.TestCase): self.assertEqual(duration_in_seconds("10m"), 600) self.assertEqual(duration_in_seconds("2h"), 7200) self.assertEqual(duration_in_seconds("3d"), 259200) + self.assertEqual(duration_in_seconds("1w"), 604800) + self.assertGreater(duration_in_seconds("1mo"), 0) def test_malformed_durations_raise(self): # Trailing characters after a valid unit must be rejected, not silently From 4d83c40e74a84b2ef17f0a47030d26b83a0f9299 Mon Sep 17 00:00:00 2001 From: kigland Date: Sat, 1 Aug 2026 10:40:02 +0800 Subject: [PATCH 3/5] preserve long duration unit spellings --- litellm/litellm_core_utils/duration_parser.py | 47 ++++++++++++++++--- .../test_duration_parser.py | 21 ++++++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 75844495c30..a2764b9cc13 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -21,6 +21,36 @@ _BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = { "monthly": "30d", } +_DURATION_UNIT_ALIASES: Final[dict[str, str]] = { + "s": "s", + "sec": "s", + "secs": "s", + "second": "s", + "seconds": "s", + "m": "m", + "min": "m", + "mins": "m", + "minute": "m", + "minutes": "m", + "h": "h", + "hr": "h", + "hrs": "h", + "hour": "h", + "hours": "h", + "d": "d", + "day": "d", + "days": "d", + "w": "w", + "wk": "w", + "wks": "w", + "week": "w", + "weeks": "w", + "mo": "mo", + "mon": "mo", + "month": "mo", + "months": "mo", +} + def _normalize_duration(duration: str) -> str: return _BUDGET_DURATION_WORD_ALIASES.get(duration.strip().lower(), duration) @@ -29,15 +59,17 @@ def _normalize_duration(duration: str) -> str: def _extract_from_regex(duration: str) -> tuple[int, str]: # Use fullmatch so trailing characters after a valid unit are rejected # rather than silently dropped (e.g. "10mb" must not parse as 10 minutes). - match = re.fullmatch(r"(\d+)(mo|[smhdw]?)", duration) + match = re.fullmatch(r"(\d+)([a-z]+)", duration) if not match: raise ValueError("Invalid duration format") - value, unit = match.groups() - value = int(value) + value, raw_unit = match.groups() + unit = _DURATION_UNIT_ALIASES.get(raw_unit) + if unit is None: + raise ValueError(f"Unsupported duration unit, passed duration: {duration}") - return value, unit + return int(value), unit def get_last_day_of_month(year, month): @@ -193,11 +225,14 @@ def _setup_timezone(current_time: datetime, timezone_str: str = "UTC") -> tuple[ def _parse_duration(duration: str) -> tuple[int | None, str | None]: """Parse the duration string into value and unit.""" - match = re.fullmatch(r"(\d+)(mo|[smhdw])", duration) + match = re.fullmatch(r"(\d+)([a-z]+)", duration) if not match: return None, None - value, unit = match.groups() + value, raw_unit = match.groups() + unit = _DURATION_UNIT_ALIASES.get(raw_unit) + if unit is None: + return None, None return int(value), unit diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index f082df78deb..60ca2d1d38d 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -405,10 +405,29 @@ class TestDurationInSeconds(unittest.TestCase): def test_malformed_durations_raise(self): # Trailing characters after a valid unit must be rejected, not silently # dropped: "10mb" previously parsed as 10 minutes. - for bad in ["10mb", "5days", "30x", "abc", "10 m"]: + for bad in ["10mb", "1h30m", "1ms", "10dogs", "30x", "abc", "10 m"]: with self.assertRaises(ValueError): duration_in_seconds(bad) + def test_existing_long_unit_spellings_remain_valid(self): + aliases = { + "30seconds": 30, + "15minutes": 900, + "6hours": 21600, + "3days": 259200, + "2weeks": 1209600, + } + for duration, expected in aliases.items(): + with self.subTest(duration=duration): + self.assertEqual(duration_in_seconds(duration), expected) + + def test_long_unit_spelling_sets_reset_interval(self): + now = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc) + self.assertEqual( + get_next_standardized_reset_time("3days", now, "UTC"), + datetime(2023, 5, 18, 0, 0, 0, tzinfo=timezone.utc), + ) + if __name__ == "__main__": unittest.main() From f1cab7d9eb1b91707fdc8b66743b2b6f85c28ce2 Mon Sep 17 00:00:00 2001 From: kigland Date: Sat, 1 Aug 2026 10:51:31 +0800 Subject: [PATCH 4/5] document duration alias lookup mutability --- litellm/litellm_core_utils/duration_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index a2764b9cc13..a82e0883720 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -21,7 +21,7 @@ _BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = { "monthly": "30d", } -_DURATION_UNIT_ALIASES: Final[dict[str, str]] = { +_DURATION_UNIT_ALIASES: Final[dict[str, str]] = { # mutable-ok: constant lookup table "s": "s", "sec": "s", "secs": "s", From dcd688ee8b649cd457784e71f124e95b0c2aaedf Mon Sep 17 00:00:00 2001 From: kigland Date: Sat, 1 Aug 2026 22:32:51 +0800 Subject: [PATCH 5/5] preserve abbreviated month spellings --- litellm/litellm_core_utils/duration_parser.py | 2 ++ tests/test_litellm/litellm_core_utils/test_duration_parser.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index a82e0883720..2a6b0116b6d 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -46,7 +46,9 @@ _DURATION_UNIT_ALIASES: Final[dict[str, str]] = { # mutable-ok: constant lookup "week": "w", "weeks": "w", "mo": "mo", + "mos": "mo", "mon": "mo", + "mons": "mo", "month": "mo", "months": "mo", } diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index 60ca2d1d38d..df0fc9938c6 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -421,6 +421,9 @@ class TestDurationInSeconds(unittest.TestCase): with self.subTest(duration=duration): self.assertEqual(duration_in_seconds(duration), expected) + self.assertEqual(duration_in_seconds("1mos"), duration_in_seconds("1mo")) + self.assertEqual(duration_in_seconds("2mons"), duration_in_seconds("2mo")) + def test_long_unit_spelling_sets_reset_interval(self): now = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc) self.assertEqual(