feat: let tpm be configured without an est_tokens companion

The gate check forced operators to guess a per-call token size before
they could enable the TPM gate at all; est_tokens is now an optional
tuning override and effective_est_tokens() derives the reservation from
the provider quota. Reservation and settlement already read the same
derived value, so the deposit still nets to zero on both the success
path and the non-dead transient failure path.

The rest of _validate_gates is untouched, and the est_tokens field plus
its EST_TOKENS env key stay put for migration compatibility.
This commit is contained in:
2026-07-30 10:57:40 -04:00
parent cd8bebba00
commit ab496bb298
4 changed files with 62 additions and 21 deletions
+34
View File
@@ -171,6 +171,28 @@ class TestSuccessPath:
# 预扣 400,实测 40+60 → settle 后窗口记 100(而非派生兜底的 400)
assert (await limiter.source_stats("a")).tpm_used == 100
async def test_settle_keeps_derived_deposit_when_usage_unavailable(self):
"""未填 est_tokens + usage 帧缺失的**成功**调用: 押金留存而非整笔退回。
入场预扣与结算须同取 `effective_est_tokens()`(delta==0),否则对
"从不返回 usage 帧"的源等于 TPM 闸进门即放行、出门即清账(设计 §3.2 #9)。
"""
src = _src("a", tpm=1000, est_tokens=0) # 派生预扣量 = max(1, 1000 // 60) = 16
result = TransportResult(
content="ok",
thinking="",
prompt_tokens=0,
completion_tokens=0,
usage_source="unavailable",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
)
mw, limiter, *_ = _harness([src], [result])
await mw(_REQ)
assert src.effective_est_tokens() == 16
assert (await limiter.source_stats("a")).tpm_used == 16
class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self):
@@ -221,6 +243,18 @@ class TestRetryAndFailover:
assert sleep.delays == [] # 源死亡不退避
assert not (await gate.try_enter("a", "w")).allowed # a 已 force_open
async def test_transient_failure_keeps_derived_deposit(self):
"""未填 est_tokens 的**非 dead 瞬时失败**同样按派生预扣量保守结算。
失败请求可能已被网关计费,退掉押金会低估用量(设计 §3.2 #8);
max_attempts=1 保证恰一次尝试,窗口残留量即单次预扣量。
"""
src = _src("a", tpm=1000, est_tokens=0) # 派生预扣量 = 16
mw, limiter, *_ = _harness([src], [TransientError("boom")], max_attempts=1)
with pytest.raises(AllSourcesExhausted):
await mw(_REQ)
assert (await limiter.source_stats("a")).tpm_used == 16
class TestNonRetryableOutcomes:
async def test_request_rejected_propagates_without_retry(self):