fix(mcp): treat a positive sub-second upstream lifetime as alive, not expired

_classify_upstream_lifetime decided "expired" from int(float(expires_in)), which truncates toward
zero, so a positive fractional lifetime in (0, 1) became 0 and was misread as already elapsed. That
rejected the mint with 502 in _finish_bridge_mint after the single-use upstream code had already been
consumed, even though the upstream reported a positive remaining lifetime.

Decide expired on the parsed numeric value rather than its truncated int, so only a genuinely
non-positive value is expired. The envelope works in whole seconds and cannot represent a sub-second
lifetime, so a positive value that truncates to 0 clamps up to the 1s floor instead of being rejected.
Values >= 1 still truncate toward zero so the envelope never claims more life than the upstream stated,
and NaN / Infinity / oversized input still read as unparseable ("unspecified").

Regression covers the classifier (0.5 and 0.001 clamp to 1, 1.9 truncates to 1, -0.5 stays expired) and
the mint (a 0.5s upstream lifetime mints a 200 envelope rather than a 502); reverting to the
truncate-then-check reddens both.
This commit is contained in:
Tin Chi Lo 2026-07-11 18:39:00 -07:00
parent a07aba0579
commit 55ff3a242c
2 changed files with 36 additions and 8 deletions

View file

@ -742,19 +742,24 @@ envelope caps it, the by-design behaviour for an upstream that omits the field."
def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']":
"""Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent
or unparseable, so the envelope caps it), or ``"expired"`` (a parseable non-positive value the
upstream reports as already elapsed). Telling "we do not know the lifetime" apart from "the upstream
says it is already dead" is what stops an explicitly-expired token from silently receiving the
envelope's 1h cap. ``bool`` is excluded (an ``int`` subclass but never a real lifetime), and
``int(float(...))`` can raise on ``NaN`` / ``Infinity`` / oversized input, which reads as
unparseable rather than surfacing as a 500."""
or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports
as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is
already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h
cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the
envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded
(an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` /
``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500."""
if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)):
return "unspecified"
try:
seconds = int(float(raw_expires_in))
numeric = float(raw_expires_in)
seconds = int(numeric)
except (ValueError, TypeError, OverflowError):
return "unspecified"
return seconds if seconds > 0 else "expired"
if numeric <= 0:
return "expired"
return max(1, seconds)
def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection":

View file

@ -4661,6 +4661,22 @@ async def test_bridge_mint_upstream_expired_lifetime_is_502():
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502():
"""A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a
(1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use
code was already consumed. Regression for classifying a sub-second remaining lifetime as expired."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert body["expires_in"] >= 0
@pytest.mark.asyncio
async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected():
"""An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never
@ -4742,6 +4758,13 @@ def test_classify_upstream_lifetime():
# explicit, parseable, non-positive -> the upstream says the token is already dead
assert _classify_upstream_lifetime(0) == "expired"
assert _classify_upstream_lifetime(-5) == "expired"
assert _classify_upstream_lifetime(-0.5) == "expired"
# a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor
# rather than truncating to 0 and being misread as expired
assert _classify_upstream_lifetime(0.5) == 1
assert _classify_upstream_lifetime(0.001) == 1
# a positive value >= 1 truncates toward zero (never overstating the stated lifetime)
assert _classify_upstream_lifetime(1.9) == 1
# unknown lifetime -> cap (never invent a longer life than the upstream stated)
assert _classify_upstream_lifetime(None) == "unspecified"
assert _classify_upstream_lifetime(True) == "unspecified"