diff --git a/src/polygateway/types.py b/src/polygateway/types.py index adb4375..b267076 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -136,8 +136,8 @@ class SourceConfig: for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"): if getattr(self, attr) < 0: raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)") - if self.tpm > 0 and self.est_tokens <= 0: - raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)") + # 注: 不再强制 `tpm > 0 ⇒ est_tokens > 0`——预扣量由 effective_est_tokens() + # 自 tpm 派生,运维只需填供应商配额页上抄得到的 tpm(设计 §3.2 #1) def _validate_watchdog(self) -> None: # CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s diff --git a/tests/contracts/test_limiter_contract.py b/tests/contracts/test_limiter_contract.py index bc102cb..8a12a2d 100644 --- a/tests/contracts/test_limiter_contract.py +++ b/tests/contracts/test_limiter_contract.py @@ -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) diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 4e0733f..01b1278 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -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): diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 6b97ef7..3965e81 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -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 # 派生不回写字段