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:
@@ -84,6 +84,23 @@ class TestTpmGate:
|
||||
await p2.release()
|
||||
assert (await limiter.source_stats("s1")).tpm_used == 600
|
||||
|
||||
async def test_settle_equal_to_prededuct_keeps_deposit(self, limiter_factory):
|
||||
"""预扣量与结算量同为派生值时,双后端都必须留存押金(delta==0)。
|
||||
|
||||
这里只锁**后端算术**: 相等的两个数进出,窗口残留量恰为该值。
|
||||
"调用点是否真的取了派生值"是编排行为,由 tests/unit/test_retry.py
|
||||
经 RetryMW 端到端覆盖,不在本契约文件重复(否则只是自证同一个入参)。
|
||||
"""
|
||||
src = make_source(tpm=6000, est_tokens=0) # 派生值 = max(1, 6000 // 60) = 100
|
||||
derived = src.effective_est_tokens()
|
||||
assert derived == 100
|
||||
limiter = limiter_factory([src], _NO_GLOBAL)
|
||||
permit = await limiter.try_acquire("s1", derived)
|
||||
assert permit is not None
|
||||
await permit.settle(derived)
|
||||
await permit.release()
|
||||
assert (await limiter.source_stats("s1")).tpm_used == derived
|
||||
|
||||
async def test_failed_acquire_leaves_no_tpm_trace(self, limiter_factory):
|
||||
src = make_source(tpm=500, est_tokens=400)
|
||||
limiter = limiter_factory([src], _NO_GLOBAL)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -33,18 +33,6 @@ def _make_source(**overrides):
|
||||
return SourceConfig(**base)
|
||||
|
||||
|
||||
def _source_without_est(tpm):
|
||||
"""构造"tpm > 0 且 est_tokens 未填"的源(该组合当前尚不能直接构造)。
|
||||
|
||||
`_validate_gates` 现仍强制 `tpm > 0 ⇒ est_tokens > 0`,解绑要到 T4 才做;
|
||||
此处先按合法组合构造、再绕开构造期校验写入 tpm,只为在 T1 阶段提前锁定
|
||||
派生逻辑本身。T4 删除该约束后,本函数可整体退化为 `_make_source(tpm=...)`。
|
||||
"""
|
||||
src = _make_source(tpm=0)
|
||||
object.__setattr__(src, "tpm", tpm)
|
||||
return src
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_eleven_legacy_fields_positional(self):
|
||||
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
|
||||
@@ -105,9 +93,11 @@ class TestSourceConfig:
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(timeout_s=0)
|
||||
|
||||
def test_tpm_requires_est_tokens(self):
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(tpm=10000, est_tokens=0)
|
||||
def test_tpm_does_not_require_est_tokens(self):
|
||||
"""`tpm > 0 ⇒ est_tokens > 0` 已解绑: 供应商配额可独立于库实现细节填写。"""
|
||||
derived = _make_source(tpm=10000, est_tokens=0)
|
||||
assert derived.est_tokens == 0
|
||||
assert derived.effective_est_tokens() == 166 # max(1, 10000 // 60)
|
||||
assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800
|
||||
|
||||
def test_negative_gate_rejected(self):
|
||||
@@ -136,12 +126,12 @@ class TestEffectiveEstTokens:
|
||||
|
||||
def test_derives_from_tpm_scale_free(self):
|
||||
"""派生量随配额同比缩放: 两种配额规模的在途上限同为 60 个调用。"""
|
||||
assert _source_without_est(tpm=6000).effective_est_tokens() == 100
|
||||
assert _source_without_est(tpm=600000).effective_est_tokens() == 10000
|
||||
assert _make_source(tpm=6000).effective_est_tokens() == 100
|
||||
assert _make_source(tpm=600000).effective_est_tokens() == 10000
|
||||
|
||||
def test_derived_floor_is_one(self):
|
||||
"""极小配额下派生量不得塌到 0——0 预扣等于 TPM 闸不设防(设计 §2.2)。"""
|
||||
assert _source_without_est(tpm=30).effective_est_tokens() == 1
|
||||
assert _make_source(tpm=30).effective_est_tokens() == 1
|
||||
|
||||
def test_zero_when_tpm_gate_disabled(self):
|
||||
"""tpm=0 即 TPM 闸未启用,无需预扣。"""
|
||||
@@ -154,7 +144,7 @@ class TestEffectiveEstTokens:
|
||||
def test_is_pure_sync_function(self):
|
||||
"""纯方法: 非协程、可重复调用、不改动自身字段(设计 §5 并发前提)。"""
|
||||
assert not inspect.iscoroutinefunction(SourceConfig.effective_est_tokens)
|
||||
src = _source_without_est(tpm=6000)
|
||||
src = _make_source(tpm=6000)
|
||||
assert src.effective_est_tokens() == src.effective_est_tokens() == 100
|
||||
assert src.est_tokens == 0 # 派生不回写字段
|
||||
|
||||
|
||||
Reference in New Issue
Block a user