25 Commits

Author SHA1 Message Date
iomgaa 8495cea5dc docs: close out the plan with the external deliverables done
Wiki site synced across six pages (commit 4c8dc09 on the wiki repo) and
issue #2 answered with the shipping conditions for the downstream
workaround removal.
2026-07-30 12:17:58 -04:00
iomgaa abca723d3d chore: release 1.0.3 with the est_tokens decoupling
Patch level: no field or env key was removed or renamed, no port
signature moved, and the API stays backward compatible -- what changed
is the telemetry data contract, which the changelog spells out for
downstream cost rollups.
2026-07-30 12:14:35 -04:00
iomgaa 63b85508c7 docs: tick off the plan items that are actually done
Leaves the wiki-site sync, the issue #2 reply and the version bump
unticked -- those are external deliverables this repository cannot
self-certify, and the pre-merge review was right to flag their absence.
2026-07-30 11:29:56 -04:00
iomgaa 4e06d5e801 docs: widen the GovDoc usage_source note to three states
The migration doc is a standing constraint on library design, so an
outdated enum there states an outdated fact. B13's substance survives
the change -- GovDoc's zero was never the problem, the missing label
was -- but the row now names unavailable and the cache_hit-qualified
gap query alongside it.
2026-07-30 11:18:44 -04:00
iomgaa 4e5a91d802 docs: log the est_tokens decoupling behavior changes 2026-07-30 11:12:43 -04:00
iomgaa 9e2d8ee43c docs: mark EST_TOKENS optional in the env template 2026-07-30 11:12:43 -04:00
iomgaa d1520cc0a5 docs: realign authoritative docs with the three-state usage_source
ARCHITECTURE.md 四处: §4.4 预扣量改指 effective_est_tokens(); §5.1 补三态值域表与
cost NULL 口径(含缓存命中行的例外与缺口查询必带 cache_hit 限定); §7.1 打捞路径
由强制 estimated 改为仅在收到 usage 帧时降级; §7.7 est_tokens 降为可选调优覆盖并
写明 tpm//60 派生规则与既有的全局 TPM 闸限制。

migrations/chsanalyzer.md 行 151 由保留改判有意放弃并写入理由; G2 标记已闭。
schemas/llm-calls.md 同步三态与 cost 口径。
2026-07-30 11:11:01 -04:00
iomgaa ab496bb298 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.
2026-07-30 10:57:40 -04:00
iomgaa cd8bebba00 refactor: drop the now-unused source parameter from usage resolvers
三态兜底不再读源配置,_resolve_usage / _resolve_stream_usage /
_resolve_embedding_usage 的 source 形参已成死参数;保留它等于在签名上继续
宣称用量口径依赖源配置,与本次改动切断该依赖的意图相悖。同步三个调用点
与测试的直接调用;SourceConfig 仍被文件内错误翻译等函数使用,import 保留。
2026-07-30 10:46:52 -04:00
iomgaa 195454d2e3 fix: stop passing est_tokens off as measured usage
usage 帧缺失/非法时不再拿 est_tokens(最坏情形上界)当实测值,chat 与
embedding 两处兜底改记 0 并标 unavailable;打捞覆盖加 measured 前置条件,
避免 0/0 被洗成 estimated 而算出假的 0.0。embedding 全批合并扩三态(任一批
不可得 → 整体不可得),_total_cost 遇不可得批整体记 NULL。
2026-07-30 10:39:32 -04:00
iomgaa 42e429eb58 fix: void the cost of rows whose usage is unavailable
失败尝试与终态失败行的 usage_source 由 estimated 改 unavailable(用量确实
不可得),并在 TelemetryEmitter 的成本换算里为 unavailable 短路记 NULL。
短路刻意插在 cache_hit 分支之后: 缓存命中未产生新调用,0.0 是事实而非未知。
附 OCR 成功行的防回归钉(仍为 measured、settle 恒 0,设计 §3.3 剔出决定)。
2026-07-30 10:37:48 -04:00
iomgaa 76e7d9594c test: lock settlement on measured usage in RetryMW 2026-07-30 10:17:31 -04:00
iomgaa d8e8fd8124 refactor: route TPM reservation and settlement through the derived value
Five call sites (QuotaGate entry, RetryMW/EmbeddingClient success and
transient-failure settlement) now read effective_est_tokens() instead of
est_tokens. Success paths gain an unavailable branch that keeps delta at
zero once usage frames may be missing; it has no producer yet, so
behaviour is unchanged while the tpm>0 => est_tokens>0 gate still holds.
2026-07-30 10:15:52 -04:00
iomgaa e5dbcf5d33 feat: derive TPM reservation and pin the usage_source domain
Task 1 of the est_tokens decoupling: capability only, no call site
touched, so library behaviour is unchanged word for word.

SourceConfig.effective_est_tokens() returns the explicit est_tokens when
set, otherwise tpm // 60 floored at 1, otherwise 0 when the TPM gate is
off. The divisor is scale free: any quota size yields the same in-flight
ceiling of roughly sixty calls, which is what makes the default
explainable where a fixed constant was not.

USAGE_SOURCES lands with the two assertions the design asks for, not as
a dead constant. test_usage_source_domain.py drives every production
point -- _resolve_usage, _resolve_embedding_usage, _merge and the three
TelemetryEmitter.emit_* helpers -- and asserts the output stays inside
the domain; it is a separate file because the assertion spans
transports, embedding and telemetry, and the innermost kernel test
should not depend on implementations. The second assertion pins the
opposite ruling: constructing LLMResponse with an out-of-domain value
must not raise, since a bare ValueError at a runtime construction point
falls outside the four error categories and would escape chat().

tpm > 0 with est_tokens = 0 is still rejected until Task 4, so the
derivation tests build the future-legal shape through a helper that
bypasses the constraint; the helper collapses back to _make_source once
the constraint is gone.
2026-07-30 10:05:11 -04:00
iomgaa 61231f7f6e docs: fold plan review into the est_tokens plan
The reviewer confirmed the T1-T4 ordering holds -- it re-derived every
intermediate state and checked that no construction path can produce
est_tokens=0 with tpm>0 before T4 -- but found four gaps.

Two existing tests go red and the plan never said so: test_types.py:94
asserts the very constraint T4 deletes, and test_embedding.py:105 is a
transport-level case for the fallback T3 rewrites, easy to miss while
looking only at test_openai_compat.py.

The T4 acceptance line claimed all three settlement sides use the
derived value, but the cancel branch never assigns actual and leaves it
at the retry.py:329 initial zero -- an implementer would have "fixed"
a branch the design freezes. Corrected here and in the design section
5 sentence it came from.

USAGE_SOURCES would have landed with no consumer, so T1 now carries the
two value-domain assertions the design asks for, including the one that
pins the no-runtime-validation ruling.
2026-07-30 09:51:01 -04:00
iomgaa 4534444ad8 docs: plan the est_tokens decoupling in five ordered tasks
The ordering is the load-bearing part. All three changes interlock and
every wrong interleaving fails silently: flipping the usage fallback to
(0, 0) before the settlement points read the derived value refunds the
whole pre-deduction on success, and flipping the embedding transport
before _merge goes three-state mislabels unavailable batches as
measured. So the plan adds the capability first, moves all five call
sites onto it while it is still equivalent, only then lets the third
state take effect, and unbinds the constraint last.

Registers both wiki entries and links the plan to its design.
2026-07-30 05:41:33 -04:00
iomgaa 9a8f5cea5a docs: register the est_tokens design in the research wiki
Records the approved option, the four rejected alternatives with their
reasons, the intentionally dropped CHS migration item, and the two
defects the independent review caught. Links the entry to m1-core-design
as a refinement, since that milestone is where est_tokens froze with
both jobs attached.
2026-07-30 05:35:49 -04:00
iomgaa 637ac51754 docs: clear review residue from the est_tokens design
The value-domain table still listed the OCR endpoint as a producer of
"unavailable" while section 3.3 had just decided to keep its "measured"
label -- an implementer following the normative table would have redone
the change that was explicitly dropped, and the guard test would fail.

Also corrects the derivation call-site count to five, qualifies the
retained conservative settlement to the non-dead transient branch only,
and pins the gap metric to "AND cache_hit = false" so cache hits, which
carry cost 0.0 by design, do not inflate it.
2026-07-30 05:13:57 -04:00
iomgaa ac7c86fdee docs: fold independent review into est_tokens design
The reviewer found two real defects. First, changing the usage fallback
to (0, 0) breaks the success-side settlement too, not just the failure
side: retry.py:338 and embedding.py:271 take actual from the same
return value, so a call whose gateway never sends a usage frame would
have its whole pre-deduction refunded -- systematic TPM undercounting.
Added as change item 9. Second, dropping the OCR item: types.py:51 and
ocr.py:9 both state OCR's zero token count is a fact, not an unknown,
so "measured" was already accurate, and relabelling it would pollute
the very metric used to justify the chosen option.

Also pins the cost short-circuit after the cache_hit branch, confines
value-domain enforcement to producers so no bare ValueError escapes
chat(), completes the authoritative-document list, and narrows the
p90 rejection to the read-port argument.
2026-07-30 05:06:00 -04:00
iomgaa fd7d9d330b docs: design est_tokens decoupling from usage fallback
Split the two jobs SourceConfig.est_tokens has been doing: TPM entry
pre-deduction, where conservative means safe, and the telemetry usage
fallback, where feeding a worst-case upper bound through the output
price inflates cost by ~26x.

Records the approved decisions: usage_source gains an "unavailable"
state whose cost is NULL, and an unset est_tokens derives from
tpm//60 so the in-flight ceiling stays scale-invariant. Also declares
the CHS "conservative accounting" migration item as intentionally
dropped, and the pre-existing global-TPM gap as knowingly unfixed.

Refs: gitea issue #2
2026-07-30 04:10:52 -04:00
iomgaa afd6101c08 test: cover the env-key messages left unguarded by mutation testing
Mutation testing showed the negative structured-retries and expected-dim checks
in the env parsing path could be deleted with every test still passing. Their
value is the env key name in the message, so they need tests that assert it.

Changelog now states the real scope of this release and warns that normalising
scope moves the Redis keys, the one change here that silently relocates runtime
state. Records the breaker threshold derivation as deliberately env-only so it
does not resurface as another round.
2026-07-30 02:31:51 -04:00
iomgaa 726f26d8bd fix: normalise scope and blank strings on the construction path too
The verifier found four more env-only behaviours of the same class the branch
was already fixing. The worst is scope: it goes straight into the Redis keys
(pgw:limit:{scope}, pgw:gate:{scope}), so one process using from_env("LLM")
and another constructing scope="LLM" by hand split the rate limit and breaker
state across two namespaces, each tracking its own quota, with no error.

Blank redis_url and pricing_path now collapse to None as from_env has always
done, so they fall into the required-field checks instead of reaching the redis
client as an unparseable URL. EmbeddingSettings gains the __post_init__ it never
had, moving its batch_size and expected_dim checks off the from_env-only path.

Also adds the cache backend whitelist test that mutation testing showed missing.
2026-07-30 02:15:32 -04:00
iomgaa c9fdff9d55 fix: keep credentials out of the DSN rewrite warning
The warning added earlier in this branch logged the whole Postgres DSN, password
included, and nothing else in the library has ever printed a connection string.
It now reports only the scheme segment, which is the part that actually changed.

Regression test asserts the password and host/path never reach the log.
2026-07-30 01:13:45 -04:00
iomgaa a65b504a3d fix: consolidate remaining assembly validation into GatewaySettings
Round two of the from_env-only validation problem. Fifteen checks still lived
in the env parsing functions: six enum domains, the redis_url requirement for
redis-backed limiter/breaker/cache, cache namespace and TTL, telemetry path and
DSN, non-negative structured retries and non-blank scope. from_settings and
direct construction bypassed all of them.

The five asserts in client.py that claimed config had already validated
redis_url and the telemetry targets now hold on every path, so they revert to
what CLAUDE.md permits: internal invariant declarations that also narrow the
Optional for type checkers. Their comments now name the method that guarantees
them, since the previous wording is exactly what went stale.

Postgres DSNs built by hand now get the SQLAlchemy +driver suffix stripped the
way from_env has always stripped it, with a warning so the rewrite is not
silent. The env path strips earlier, so it stays quiet.
2026-07-30 00:58:33 -04:00
iomgaa 8c9e1179bc docs: design second round of settings validation consolidation
The independent verifier found 15 more checks still living only in from_env:
six enum domains, seven conditional-required pairs and two scalar ranges.
More severe than round one because client.py has five asserts that claim
config already validated the redis_url and telemetry paths, which is false on
the from_settings path.

Also records a normalisation gap the verifier missed: _load_pg_dsn strips the
SQLAlchemy +asyncpg suffix, so a hand-built DSN reaches asyncpg unstripped.
2026-07-30 00:46:44 -04:00
34 changed files with 1755 additions and 79 deletions
+2 -2
View File
@@ -8,11 +8,11 @@ LLM__QWEN__1__BASE_URL=
LLM__QWEN__1__API_KEY= LLM__QWEN__1__API_KEY=
LLM__QWEN__1__MODEL= LLM__QWEN__1__MODEL=
LLM__QWEN__1__TIMEOUT_S=120 LLM__QWEN__1__TIMEOUT_S=120
# 可选(0 = 该闸不启用;TPM > 0 时 EST_TOKENS 必填 > 0): # 可选(0 = 该闸不启用):
# LLM__QWEN__1__MAX_CONCURRENCY=8 # LLM__QWEN__1__MAX_CONCURRENCY=8
# LLM__QWEN__1__RPM=60 # LLM__QWEN__1__RPM=60
# LLM__QWEN__1__TPM=100000 # LLM__QWEN__1__TPM=100000
# LLM__QWEN__1__EST_TOKENS=2000 # LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout # LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15 # LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭 # LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## 1.0.3(2026-07-30)
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
### 行为收紧/变更(下游请读)
- **`usage_source` 新增第三个值 `unavailable`。** 值域由 `measured`/`estimated` 两态变三态:`unavailable` 表示用量信息不可得(usage 帧缺失、失败尝试、终态失败),`estimated` 收窄为"有实测数字但可信度降级"(只剩打捞路径这一个生产者:收到 usage 帧但流被截断)。历史库里既有的 `estimated` 行语义不变、读兼容;按 `usage_source` 分支的下游代码需要认识新值。OCR 成功行**不受影响**,仍是 `measured`(0 token 是事实而非未知)。
- **用量不可得的行,`cost` 由数值变 NULL。** 此前 usage 帧缺失时库拿 `est_tokens`(按定义是最坏情形上界)当实测值,又整块塞进 `completion_tokens` 换算——输出单价通常是输入的数倍,实测双重高估约 26 倍;`est_tokens=0` 时则算出 `0.0`,让"免费"与"未知"在数据上不可区分。现在这类行如实记 `0/0` + `unavailable` + `cost=NULL``SUM(cost)` 天然跳过 NULL,账目缺口用 `WHERE usage_source = 'unavailable' AND cache_hit = false` 量化(**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 仍是事实上的 `0.0`,本无缺口)。成本汇总若此前依赖"cost 非空"的隐含假设,请复核。
- **`est_tokens` 由必填降为可选调优覆盖。** 装配校验 `tpm > 0 ⇒ est_tokens > 0` 已删除——它把供应商配额(运维能从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。未填时库按 `max(1, tpm // 60)` 派生("一次调用约占一秒钟的配额份额",尺度无关:任何配额规模都收敛到约 60 个在途)。字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**,显式填值仍然优先。此前为绕开该校验而把 `tpm` 限死为 0 的调用方,现可填真实 TPM。
## 1.0.2(2026-07-30)
1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。
### 修复
- **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行:`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full` 六个字段的合法域;取 `redis` 的后端必须有 `redis_url`;启用缓存必须有 `cache_namespace` 与正 `cache_ttl_s`;`telemetry_backend``sqlite`/`postgres` 时对应的路径/DSN 必填;`structured_max_retries` 非负;`scope` 非空。
- **`client.py` 五处断言的前提现在真的成立。** `assert settings.redis_url is not None # 内部不变量: config 已校验` 之类的注释此前在 `from_settings` 路上是假的:断言开启时抛不含任何字段信息的 `AssertionError`,`python -O` 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
- **构造路补齐了 `from_env` 一直在做的规范化**,两条装配路对同一输入产出同一个值:
- `scope` 小写并去空白。它直接进 Redis key(`pgw:limit:{scope}:…``pgw:gate:{scope}:…`),此前一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。
- `redis_url``pricing_path` 的空串归 `None`。留着空串会骗过 `is None` 判断,把错误推迟成 redis 客户端的连接串解析异常或 `Is a directory: '.'`
- Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://…``+asyncpg` asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 `from_env` 装配的不受影响也不会有这条 warning(`_load_pg_dsn` 早就剥干净了)。
- **`EmbeddingSettings``batch_size` / `expected_dim` 域校验也移入构造期**,此前只有 `EmbeddingSettings.from_env` 校验,直接构造出 `batch_size=-3` 要到 `EmbeddingClient` 构造时才 fail-loud。
### 行为收紧(下游请读)
同 1.0.1:经 `from_env()` 装配的调用方**不受影响**。手工构造 `GatewaySettings` 或对它 `dataclasses.replace` 的调用方,若配置组合非法,现在会在构造期抛 `ValueError` 并点出字段名,而不是留到运行时表现为静默不建后端、裸 `AssertionError` 或第三方库的天书报错。
**一处静默改值需要留意**:此前手工构造传 `scope="LLM"`(非全小写)的调用方,升级后 scope 会被规范化为 `llm`,**Redis key 随之从 `pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`**。这正是本次要修的问题——旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。
## 1.0.1(2026-07-30) ## 1.0.1(2026-07-30)
### 修复 ### 修复
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.0.1" version = "1.0.3"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+23 -5
View File
@@ -302,7 +302,7 @@ flowchart TB
### 4.4 一次调用的生命周期(walkthrough) ### 4.4 一次调用的生命周期(walkthrough)
1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。 1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按 `est_tokens` 预扣)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。 2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按**有效预扣量** `SourceConfig.effective_est_tokens()` 预扣,取值规则见 §7.7)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。 3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。
4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。 4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。
5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。 5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。
@@ -322,13 +322,25 @@ flowchart TB
| `content` | str | 正式输出文本 | | `content` | str | 正式输出文本 |
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) | | `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
| `model` / `provider` | str | 溯源 | | `model` / `provider` | str | 溯源 |
| `prompt_tokens` / `completion_tokens` | int | usage 帧读取;缺失时按估算标注 | | `prompt_tokens` / `completion_tokens` | int | usage 帧读取;缺失时`0/0` 并由 `usage_source` 标注不可得(不编造估算值,见下) |
| `latency_ms` | int | 总延迟 | | `latency_ms` | int | 总延迟 |
| `ttft_ms` / `max_inter_token_ms` | float? | 流式活性测量 | | `ttft_ms` / `max_inter_token_ms` | float? | 流式活性测量 |
| `cache_hit` | bool | 是否缓存命中 | | `cache_hit` | bool | 是否缓存命中 |
| `call_id` | str | UUID,每次**尝试**独立 | | `call_id` | str | UUID,每次**尝试**独立 |
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(measured/estimated)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。 新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
| 值 | 含义 | 生产者 | cost |
|---|---|---|---|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
**cost 口径的不变式**: **产生了真实网关调用、但用量不可得的行 → `cost` 为 NULL**(不再算出一个假的 `0.0` 把"免费"与"未知"混为一谈)。**缓存命中行不在此列**——`cache_hit=True` 时 cost 仍为 `0.0`,因为未产生新调用,`0.0` 是事实而非未知;`TelemetryEmitter``unavailable → None` 的短路**插在 `cache_hit` 分支之后**正是为此。故账目缺口的度量口径必须写成 `WHERE usage_source = 'unavailable' AND cache_hit = false`,漏掉后半个条件会把本无缺口的缓存命中行灌进来,度量偏高。
**API 稳定性约定(2026-07-20,迁移文档反向约束)**: ① 公共类型新增字段必须带默认值——三项目测试中逐字段传参的 fake 构造才能零改动;② 错误四分类从 `polygateway` 顶层命名空间导出——业务侧步级重试要引用它们(GovDoc/Video-Tree 现有 `(TimeoutError, OSError)` 异常元组迁移后会**静默失效**,必须显式替换为库异常);③ `GatewayClient` 提供显式 `aclose()` 与 async context manager 生命周期 API;④ 被取消的调用尽力而为记遥测(error="cancelled",finally 中记录,绝不因遥测延迟取消传播,写失败静默)。 **API 稳定性约定(2026-07-20,迁移文档反向约束)**: ① 公共类型新增字段必须带默认值——三项目测试中逐字段传参的 fake 构造才能零改动;② 错误四分类从 `polygateway` 顶层命名空间导出——业务侧步级重试要引用它们(GovDoc/Video-Tree 现有 `(TimeoutError, OSError)` 异常元组迁移后会**静默失效**,必须显式替换为库异常);③ `GatewayClient` 提供显式 `aclose()` 与 async context manager 生命周期 API;④ 被取消的调用尽力而为记遥测(error="cancelled",finally 中记录,绝不因遥测延迟取消传播,写失败静默)。
@@ -381,7 +393,7 @@ flowchart TB
**职责**: 一次原始调用的全部协议细节——请求体组装(含 provider 注册表注入的 thinking 参数)、发送、流式 SSE 解析(增量 content/reasoning_content、usage 帧、[DONE] 检测)、HTTP/线路错误按 §6.2 翻译。**不含**重试/限流/缓存(那是中间件的事)。 **职责**: 一次原始调用的全部协议细节——请求体组装(含 provider 注册表注入的 thinking 参数)、发送、流式 SSE 解析(增量 content/reasoning_content、usage 帧、[DONE] 检测)、HTTP/线路错误按 §6.2 翻译。**不含**重试/限流/缓存(那是中间件的事)。
- `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径强制 `usage_source="estimated"`。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。 - `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径**仅在收到 usage 帧时**把 `measured` 降级为 `estimated`(**勘误 2026-07-30**: 原文"强制 estimated" 已改为有条件——没收到 usage 帧时用量本就是 `unavailable`,强制标 `estimated` 会让 `0/0` 被当作实测数字换算出一个假的 `0.0` 成本,§5.1)。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
- `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。 - `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。
- `MonkeyOcrTransport`: 见 §7.10。 - `MonkeyOcrTransport`: 见 §7.10。
@@ -425,7 +437,13 @@ flowchart TB
### 7.7 多源与选源 ### 7.7 多源与选源
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣常量,亦作 usage 缺失时的保守兜底,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。 `SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
派生取 `tpm // 60` 的理由是**尺度无关**:任何配额规模都给出同一行为上限——"一次调用约占一秒钟的配额份额",故 `tpm=6000``tpm=600000` 都收敛到约 60 个在途。固定常量(如 1000)则与配额规模无关,在途上限随配额乱飘且取值无从解释。`est_tokens` **不再兼任 usage 缺失时的用量兜底**:那两份差事对"保守"的定义方向相反——限流语境下押多了只是慢(安全),计费语境下按上界记账只会系统性虚高(库把遥测拆成 prompt/completion 两列后又整块塞进 completion,而输出单价通常是输入的数倍,实测双重高估约 26 倍)。用量不可得现在如实记 `unavailable` + cost NULL(§5.1)。
**已知限制(既有行为,本次未修)**: 单源 `tpm == 0``{SCOPE}__GLOBAL__TPM > 0` 时,派生值为 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。修它需要把 `GlobalLimits` 注入 `QuotaGate`(改三处装配),属独立议题。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。
**多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5)**: 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client **显式注入同一个状态后端实例**时即共享全局并发/RPM/TPM 闸(Video-Tree `TREE_BUILD_API_CONCURRENCY` 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。 **多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5)**: 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client **显式注入同一个状态后端实例**时即共享全局并发/RPM/TPM 闸(Video-Tree `TREE_BUILD_API_CONCURRENCY` 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。
@@ -0,0 +1,152 @@
# est_tokens 解耦设计(issue #2)
- **日期**: 2026-07-30
- **触发**: Gitea issue #2《est_tokens 应由库按实测自估,而不是让调用方填一个没有正确取值的常量》
- **档位**: 强制档(改公共 API 语义 + `usage_source` 公共值域 + 推翻一条已声明保留的迁移行为)→ 需人类审批门
- **修订的权威文档**(经独立审查补全):
- `ARCHITECTURE.md` §7.7 行 428(`SourceConfig.est_tokens` 描述)、§5.1 行 331(`usage_source` 值域)、**§4.4 行 305**("token 按 `est_tokens` 预扣")、**§7.1 行 384**("打捞路径强制 `usage_source="estimated"`",因 §3.2 #4 变为有条件)
- `migrations/chsanalyzer.md` 行 151 与 G2(行 185)
- **`.env.example` 行 11**("TPM > 0 时 EST_TOKENS 必填 > 0",约束已废除)
## 1. 问题:一个常量被派了两份互相矛盾的差事
`SourceConfig.est_tokens` 同时承担两个职责,而两者对"保守"的定义方向相反:
| 职责 | 语境 | "保守"意味着 | 填大的后果 |
|---|---|---|---|
| TPM 入场预扣 | 限流 | 多押金,宁可压吞吐也不击穿网关 | 安全(只是慢) |
| usage 缺失时的用量兜底 | 计费 | **不存在保守方向** | 账单虚高 |
CHS 原版 `config.py:55` 把它定义为"须 ≥ 最坏情形 token"——按定义是**上界**。拿上界当实测值记账,必然系统性高估。库把遥测拆成 `prompt_tokens`/`completion_tokens` 两列后又把整个估值塞进 `completion`(`openai_compat.py:146`),而 `pricing.py:70-72``prompt×input价 + completion×output价` 换算,输出单价通常是输入的数倍——**双重高估**。
实测算例:`est_tokens=4000`,单价输入 1 元/百万、输出 8 元/百万,真实消耗 400+100:
| | 记账 token | cost |
|---|---|---|
| 真实 | 400 / 100 | 0.0012 元 |
| 现状 | 0 / 4000 | 0.032 元(**26 倍**) |
第二个症状是装配约束:`types.py:125``tpm > 0 ⇒ est_tokens > 0` 把供应商配额(运维可从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。下游 CHSAnalyzer 删掉 `est_tokens` 配置项后,`tpm` 就再也不能填非 0,只能在自己的配置模型里把 `tpm` 限死为 0 绕开——库把内部细节泄漏进了配置面。
## 2. 备选方案对比
### 2.1 决策点一:usage 不可得时遥测记什么
| 方案 | 做法 | 权衡 |
|---|---|---|
| **A(选定)** | 记 `0/0`,`usage_source` 扩一个 `unavailable`,cost 记 NULL | 缺数据可被统计:`SUM(cost)` 跳过 NULL,`COUNT(*) WHERE usage_source='unavailable' AND cache_hit = false` 能量化账的缺口(**必须带 `cache_hit` 限定**:按 §3.2 #5 的裁决,缓存命中行可以既是 `unavailable` 又有 `cost=0.0`,它们本无账目缺口,不加限定就会灌水——与 §3.3 剔出 OCR 用的是同一把尺子)。代价:公共值域变更,需进 CHANGELOG,且该查询口径要一并写进 wiki(§8) |
| B | 记 `0/0`,沿用 `estimated` | 改动最小(等于把 `est_tokens>0` 路径统一到 `est_tokens=0` 的现状行为)。**否决**:cost 算出 `0.0`,"免费"与"未知"在数据上不可区分,缺口不可量化 |
| C | 保留 est 兜底,只修 `prompt`/`completion` 分配比例 | 保住 CHS"保守计量"意图。**否决**:比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"这个根因,仍高估约 9 倍 |
### 2.2 决策点二:`est_tokens` 未填时的默认预扣量
先排除"不预扣":`try_acquire` 传 0 会让 TPM 窗口在请求飞出到 settle 回来的整段时间形同虚设,大批请求可同时入场,正是"防击穿网关"要防的场景,与 CLAUDE.md 降级方向铁律相悖。
| 方案 | 源甲 `tpm=6000` | 源乙 `tpm=600000` | 权衡 |
|---|---|---|---|
| **派生 `tpm//60`(选定)** | 押 100 → 60 个在途 | 押 10000 → 60 个在途 | 尺度无关:任何配额规模都给出同一行为上限,语义可写进 docstring("一次调用约占一秒钟的配额份额") |
| 固定常量 1000 | 押金占配额 1/6 → 仅 6 个在途,小请求场景白慢数倍 | 押金占 1/600 → 600 个在途,大请求场景照样撞 429 | **否决**:常量与配额规模无关,在途上限随配额乱飘,无法解释取值 |
### 2.3 派生逻辑的落点
| 方案 | 权衡 |
|---|---|
| **`SourceConfig.effective_est_tokens()`(选定)** | 纯方法只读自身字段,落 `types.py` 内核不违反依赖铁律;零装配变更、零端口变更;5 个调用点(`QuotaGate` 入场 + retry/embedding 各自的成功侧与失败侧结算)共用一份 |
| 注入 `GlobalLimits``QuotaGate`,派生取全局与单源 tpm 的较紧者 | 能覆盖"单源 `tpm=0` 而全局 `tpm>0`"的场景。**否决**:需改三处装配(`retry.py:186`/`embedding.py:116`/`ocr.py:119`),且它修的是一个**既有**缺口(见 §7),超出本任务范围 |
| 派生下沉到两个 limiter 后端 | **否决**:`try_acquire(source_key, est_tokens)` 的入参会变成谎言(后端忽略它),且逻辑要写两遍,违反 D3"语义契约只有一份"与 P7"决策与存储分离" |
## 3. 选定方案
### 3.1 `usage_source` 三态值域
| 值 | 含义 | 生产者 | cost |
|---|---|---|---|
| `measured` | usage 帧完整可信 | 正常路径 | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL**(缓存命中行例外,见 §3.2 #5) |
`estimated` 保留且有真实生产者(打捞),同时保证历史库里既有的 `estimated` 行读兼容。
**不变式的准确表述**: 产生了真实网关调用、但用量不可得的行 → cost 为 NULL。缓存命中行不在此列(见 §3.2 #5)。
**值域的强制落点**: `types.py` 模块级 frozenset 常量,仅约束**库内生产侧**——所有写入 `usage_source` 的位置从该常量取值,测试断言库内产出恒在三态内。**不在 `LLMResponse`/`Usage`/`TransportResult` 等 frozen dataclass 上加 `__post_init__` 值域校验**,两条理由:① 它们是运行时构造点(如 `retry.py:418`),裸 `ValueError` 不属 `errors.py` 四分类,`RetryMW` 不捕它,会直接逃出 `chat()`,违反错误分类驱动铁律;② `LLMResponse` 是三项目已消费的公共类型,新增运行时校验是下游可见行为变更,超出本任务。故 §6 的值域测试断言"库内所有生产点的产出值落在三态内",而非"越界字符串被拒"。
### 3.2 逐处改动
| # | 位置 | 改动 |
|---|---|---|
| 1 | `types.py:125` | 删除 `tpm > 0 ⇒ est_tokens > 0`;`est_tokens` 保留字段、语义降为"可选调优覆盖" |
| 2 | `types.py` `SourceConfig` | 新增 `effective_est_tokens()`:显式值 > 0 则原样返回;否则 `tpm > 0` 时返回 `max(1, tpm // 60)`,`tpm == 0` 时返回 0 |
| 3 | `openai_compat.py:146,176` | 两处兜底改为 `(0, 0, "unavailable")` / `(0, "unavailable")`,不再读 `source.est_tokens` |
| 4 | `openai_compat.py:336` | 打捞覆盖加条件:仅当 `usage_source == "measured"` 时降级为 `estimated`,否则保持 `unavailable`(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`) |
| 5 | `middleware/telemetry.py:130-135` | cost 分支增加短路:`usage_source == "unavailable"``None`。**插在 `cache_hit` 分支之后**:缓存命中未产生新调用,`0.0` 是事实而非未知,既有"缓存命中 0.0"语义保持不动。故 `cache_hit=True``usage_source="unavailable"` 的行 cost 仍是 `0.0`,与 §3.1 不变式不冲突(那条只管产生了真实调用的行) |
| 6 | `middleware/telemetry.py:58,100` | 失败尝试与终态失败的 `usage_source``estimated``unavailable`(用量确实不可得;这两行 cost 本已是 None,语义对齐不改金额) |
| 7 | `middleware/ratelimit.py:26` | `source.est_tokens``source.effective_est_tokens()` |
| 8 | `retry.py:370``embedding.py:294` | **失败侧**保守结算改用 `effective_est_tokens()`。必须同改:预扣派生值而结算退 `est_tokens=0` 会让 `delta` 为负、退掉全部押金,丢掉"失败可能已被计费"的保守意图 |
| 9 | `retry.py:338``embedding.py:271` | **成功侧**结算:`usage_source == "unavailable"` 时按 `effective_est_tokens()` 结算,而非 `prompt+completion`(此时恒为 0)。**这条是保持既有行为、不是新增保守**:改前 `_resolve_usage` 恰好返回 `est_tokens`,使 `actual == 预扣量``delta == 0`、押金留存;#3 把它改成 `(0, 0)` 后若不同改,成功调用的押金会被整笔退回,对"从不返回 usage 帧的网关源"构成系统性 TPM 计量失效——闸门退化成进门即放行、出门即清账,正是降级方向铁律要防的击穿 |
| 10 | `embedding.py:383,390` | 二值合并扩为三态:任一批 `unavailable` → 整体 `unavailable`;否则任一 `estimated``estimated`;否则 `measured`。同步更新 `types.py:273` 的行内注释 `# measured | estimated`,内核里不留与三态矛盾的注释 |
| 11 | `embedding.py:397` `_total_cost` | 存在 `unavailable` 批时整体 cost 记 NULL(逐批求和会给出一个偏低却看似有效的金额) |
### 3.3 明确不改的
**非 dead 的瞬时失败路径**(`retry.py:369``if not dead` 分支)按预扣量做**限流**结算的行为保留——那是限流语境,保守方向正确(失败请求可能已被网关计费),且该值只流向 `_settle_and_release`,不进遥测。其余三条失败分支(`RequestRejectedError`/`ResultInvalidError`/`SourceDeadError`)的 `actual` 停在初值 0(`retry.py:329`),属既有行为,本次**不动**——#8 已把行号钉死,实现时不要顺手把这三条也改成保守结算。`SourceConfig.est_tokens` 字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**(迁移兼容硬约束,ARCHITECTURE §5.1)。`RateLimiter` 端口签名不变。
**`ocr.py:411``usage_source="measured"` 保留不改**(初稿曾列为改动项,独立审查后剔出)。库既有立场是 OCR 的 0 token 属**事实**而非未知——`types.py:51` "token 用量;OCR 等无计费调用填 0"、`ocr.py:9` "settle 恒为 0(OCR 无 token 计费)"——故 `measured` 是准确陈述。改成 `unavailable` 还会反噬 §2.1 的核心度量:`COUNT(*) WHERE usage_source='unavailable'` 本用于量化账目缺口,灌进本无缺口的 OCR 行就失去意义。
## 4. 旧版行为审计(迁移保留项的推翻声明)
| 旧版行为 | 出处 | 本次处置 |
|---|---|---|
| usage 缺失按 `est_tokens` 估算并标 `estimated`,不静默用 0 | CHS `invokers.py:241-254`;`migrations/chsanalyzer.md:151` 标记为**保留** | **有意放弃**。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆两列后无法忠实分配,且 `est_tokens` 按 CHS 自身定义是最坏情形上界。"保守"在限流语境安全、在计费语境只有错误一个方向 |
| 缺失时不静默用 0(拒绝 VT 的"填 0 且不标注") | 同上;`m1-core-design.md:222` 行 10 | **保留**。本方案记 0 但带 `unavailable` 显式标记且 cost 为 NULL,反静默的原始意图完整保留——被放弃的只是"编一个数字"这个手段 |
| `est_tokens` 作 TPM 入场预扣常量 | CHS `config.py:55` | **保留**,仅由必填降为可选覆盖 |
| `tpm > 0 ⇒ est_tokens > 0` 装配校验 | `m1-core-plan.md:93` | **替换**为库内派生,校验删除 |
| 打捞路径强制 `estimated` | `m1-core-design.md` §6 | **保留**,补一个前置条件(§3.2 #4) |
| 遥测 `INSERT OR IGNORE` 幂等、写失败降级不冒泡、列只增 | `m1-core-design.md:218` | **保留**,本次无 DDL 变更 |
## 5. 非功能维度
**并发与取消**: `effective_est_tokens()` 是无状态纯方法(只读 frozen dataclass 字段),并发安全、无锁、可重复调用。本次改动不新增 `await` 点、不改变任何 `try/finally` 结构,取消穿透路径与 in-flight 释放语义原样不动。#8#9 合起来保证**成功侧与非 dead 瞬时失败侧**的预扣与结算恒取同一派生值(`delta == 0`)——这是本设计里最容易漏的一致性约束(初稿只写了失败侧,独立审查发现成功侧缺口)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧不在此列**:它们的 `actual` 停在 `retry.py:329` 的初值 0、全额退回,属 §3.3 声明不动的既有行为。
**降级方向**: 不改变任何后端的降级方向。遥测侧仍是静默降级(`telemetry.py:161` 的 warning 不冒泡);限流侧仍是 `GovernanceBackendError` 上抛而非放行;TPM 计量不因 usage 帧缺失而静默失效(#9)。
否决 issue 建议的 p90 自估,主论据是 **`TelemetryRecorder` 目前是纯只写端口,自估需要新增读接口并强制所有后端(含 `none`)实现**,公共 API 扩张远大于它要省掉的一个可选字段,且尚无实测证据表明派生默认值不够用(§8)。初稿曾论证"那会把两条方向相反的降级铁律焊在一起",此论据经审查后**撤回**:p90 方案完全可以在遥测读失败时回退到纯派生值,限流侧仍能保持 fail-closed,故并非必然冲突。结论不变,理由收窄。
**幂等与重复**: `Permit.settle()`/`release()` 的幂等 flag 语义不变。#8#9 使预扣与结算取自同一派生函数,同一请求重复结算仍是 no-op。
**持久化与原子性**: 无 DDL 变更(两 schema 的 `cost` 列已可空);无新增落盘点;Redis Lua 脚本不改(仍接收调用方算好的 est)。历史数据不迁移:旧行的 `estimated` 语义在新值域中依然合法可读。
## 6. 错误处理与测试策略
值域校验失败属配置/内部不变量违反 → `ValueError`(装配期 fail-loud),不进四分类运行时错误。本次不改变任何调用失败的分类归属。
| 测试 | 断言要点 | 文件 |
|---|---|---|
| 约束解绑 | `tpm=6000, est_tokens=0` 构造成功(改前抛 ValueError) | `tests/unit/test_types.py` |
| 派生尺度无关 | `tpm=6000→100``tpm=600000→10000``tpm=0→0`、显式值优先、`tpm=30→max(1,·)` 不为 0 | 同上 |
| cost 不再造假 | `est_tokens=4000` + usage 缺失 → `0/0/unavailable``record_llm_call` 收到 `cost=None`(改前 `0.032`) | `tests/unit/test_openai_compat.py``test_telemetry.py` |
| 缓存命中不受牵连 | `cache_hit=True``unavailable` → cost 仍为 `0.0`(锁定 §3.2 #5 的分支次序) | `test_telemetry.py` |
| 打捞前置条件 | 打捞 + usage 帧存在 → `estimated` 且 cost 非 None;打捞 + usage 缺失 → `unavailable` 且 cost 为 None(回归 §3.2 #4) | `test_openai_compat.py` |
| **失败侧**结算不退多 | 未填 `est_tokens``tpm>0` 时失败请求,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #8) | `tests/contracts/test_limiter_contract.py` |
| **成功侧**结算不退多 | usage 缺失的**成功**调用后,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #9,本设计最易漏的一条)。现有锚点 `test_retry.py:149``_src("a", tpm=1000, est_tokens=400)` 旁加一个 `est_tokens=0` + usage 缺失的用例 | `tests/unit/test_retry.py``test_limiter_contract.py` |
| 三态合并 | 混合批 `measured+unavailable` → 整体 `unavailable` 且 cost 为 NULL | `tests/unit/test_embedding.py` |
| OCR 不变 | OCR 成功行仍为 `measured` 且 settle 恒 0(防回归,锁定 §3.3 的剔出决定) | `tests/unit/test_ocr_client.py` |
| 值域封闭 | 库内所有生产点的产出恒落在三态内;公共 dataclass 不因越界值抛异常(锁定 §3.1 的落点决定) | `test_types.py` |
限流侧断言随 `tests/contracts/test_limiter_contract.py` 同时覆盖内存与 Redis 两后端(Redis 走真实实例,遵守共享后端不并跑纪律)。
## 7. 已知限制(本次不修,显式声明)
单源 `tpm == 0` 而全局 `tpm > 0` 时,`effective_est_tokens()` 返回 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。**这是既有行为**(现状约束只管 `cfg.tpm > 0`,该场景下 `est_tokens=0` 本就合法),本方案不引入也不修复它。修它需要把 `GlobalLimits` 注入 `QuotaGate`(§2.3 备选二),属独立议题,建议另开 issue。
## 8. 下游影响与发布
`est_tokens` 从必填降为可选后,CHSAnalyzer 可删掉"`tpm` 必须为 0"的绕行校验并填真实 TPM。`usage_source` 出现第三个值、且不可得行的 cost 由数值变 NULL,是下游可见的行为变更:成本汇总若此前依赖"cost 非空"隐含假设需复核。按 `docs-convention.md` §2,发版须同步 CHANGELOG 与 wiki 的 usage/成本口径说明,并在 issue #2 回帖结论。
遥测驱动的自适应预估(issue 原建议)不在本次范围,待默认派生值在真实负载下出现实测问题后再评估。
## 9. 规模判定
改动面(独立审查后重算):**6 个源文件**(`types.py``transports/openai_compat.py``middleware/telemetry.py``middleware/ratelimit.py``middleware/retry.py``embedding.py`;`ocr.py` 已剔出)、**7 个测试文件**、**3 份权威文档**(ARCHITECTURE.md、`migrations/chsanalyzer.md``.env.example`),外加按 `docs-convention.md` §2 必须同步的 CHANGELOG 与用户文档站 wiki(版本 bump 不得裸发)。
属跨多文件功能 → 本设计经人类审批后须走 `writing-plans` 出实施计划,不得直接进实现。
@@ -0,0 +1,164 @@
# GatewaySettings 装配校验补齐(第二轮)
- **日期**: 2026-07-30;**状态**: **已批准并实施**(2026-07-30 人类门通过;§9 结论、§10 实施留痕)
- **缘起**: [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md) §9.1 —— 独立 verifier 在第一轮交付后发现,`from_env` 上还留着一批同族校验;本设计是那一轮的续作,**同一个 bug 类的剩余部分**
- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
## 1. 待收拢的校验清单(逐条实测确认只在 `from_env` 生效)
### A. 枚举合法域(6 条)
| 字段 | 合法域 | 现居 |
|---|---|---|
| `limiter_backend` / `breaker_backend` | `{memory, redis}` | `_load_pgw`(经 `_load_choice`) |
| `cache_backend` | `{redis, memory, none}` | `_load_pgw` 内联 |
| `telemetry_backend` | `{sqlite, postgres, none}` | `_load_pgw` 内联 |
| `selector` | `_SELECTORS` | `from_env``_load_choice` |
| `quota_full` | `_QUOTA_FULL` | `from_env``_load_choice` |
直接构造传 `selector="random"``cache_backend="rediss"` 一律放行,后果是装配时落进 `_build_*` 的 else 分支或静默不建后端。
### B. 条件必填(7 条,跨字段)
| 条件 | 要求 | 违反后果 |
|---|---|---|
| `limiter_backend`/`breaker_backend`/`cache_backend``redis` | `redis_url` 非空 | **见 §2**,最严重 |
| `cache_backend != "none"` | `cache_namespace` 非空 | 缓存 key 失去租户隔离——踩"无缓存毒化"铁律 |
| `cache_backend != "none"` | `cache_ttl_s > 0` | `from_env` 明令禁止的"永不过期"从另一条路进来 |
| `telemetry_backend == "sqlite"` | `telemetry_sqlite_path` 非空 | 断言炸或写空路径 |
| `telemetry_backend == "postgres"` | `telemetry_pg_dsn` 非空 | 同上 |
### C. 标量域(2 条)
`structured_max_retries ≥ 0`;`scope` 非空(空 scope 会污染遥测与缓存命名空间)。
## 2. 为什么这批比第一轮更严重:`client.py` 的断言前提为假
`client.py` 有 5 处断言**明文声称这个前提已经成立**:
```python
assert settings.redis_url is not None # 内部不变量: config 已校验
```
位置:`client.py:262/282/302`(redis_url)、`:312`(pg_dsn)、`:316`(sqlite_path)。走 `from_settings` 时该注释是假的,verifier 实测:
| 运行方式 | 结果 |
|---|---|
| 断言开启 | `AssertionError()` —— 裸断言,不点字段、不说原因 |
| `python -O` | 断言消失,退化为 redis 库的 `ValueError: Redis URL must specify one of the following schemes...` |
后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。
**但注意结论的方向**:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。
## 3. 方案
沿用第一轮已批准的方案 A,不重新论证:全部收进 `GatewaySettings.__post_init__`,新增三个私有方法与既有四个并列。
| 方法 | 覆盖 |
|---|---|
| `_validate_backends` | A 类 6 条枚举 + B 类 redis_url 三条件 |
| `_validate_cache` | `cache_namespace` 非空、`cache_ttl_s > 0`(仅 `cache_backend != "none"` 时) |
| `_validate_telemetry` | sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 |
标量两条(`structured_max_retries``scope`)并入 `_validate_sources` 改名后的 `_validate_identity`,与 `SourceConfig._validate_identity` 同名同职。
枚举合法域上提为模块级 frozenset 常量(`_LIMITER_BACKENDS` 等),`_load_pgw``__post_init__` 共用一份,消除现有的内联字面量重复。
**否决的替代**:在 `_build_limiter`/`_build_cache` 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 `dataclasses.replace` 仍绕过。
## 4. 5 处 assert 的处置:**保留,不改**
修好构造期校验后,`settings.redis_url is not None` 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 `str | None`。此时删掉 assert 反而丢失类型信息,改成 `raise` 则是在防御一个已被构造期排除的情况(死代码)。
**要改的是注释**:`# 内部不变量: config 已校验` 应点明由谁保证,例如 `# 内部不变量: GatewaySettings._validate_backends 已保证`。前一轮的教训就是这类注释会随时间变成谎言。
## 5. Postgres DSN:校验而非规范化(本轮唯一的新决策)
`_load_pg_dsn``from_env` 读到的 DSN 做了**规范化**:剥掉 SQLAlchemy 风格的 `+asyncpg` 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,`postgresql+asyncpg://...` 会原样送进 asyncpg 然后在首次写遥测时才炸。
| 选项 | 权衡 |
|---|---|
| A. 构造期校验,含 `+driver` 即报错 | 显式,库不碰用户给的值;但两条装配路对同一输入接受度不同 |
| B. 构造期静默剥后缀 | 两条路完全对齐;但 frozen 类在构造期悄悄改字段,调用方不知情 |
| **C. 构造期剥后缀 + `logger.warning`(用户 2026-07-30 拍板)** | 两条路行为对齐,同时不静默——调用方在日志里看得见库动了他的值,想根治就自己改 DSN |
选 C。实现要点:`object.__setattr__` 改 frozen 字段(`SourceConfig` 无此先例,但 frozen 的约束是对**外部**不可变,构造期规范化是既有 dataclass 惯用法);warning 走 loguru(核心依赖,库内 `ocr.py:183`/`embedding.py:318` 同款用法)。
**warning 不会打扰 env 用户**:`_load_pg_dsn` 保留现有的剥离逻辑,`from_env` 传给构造函数时 DSN 已经干净,`__post_init__` 无事可做。只有手工构造传了带后缀的 DSN 才会触发。三项目 `.env` 里那些 SQLAlchemy 写法不会每次装配刷一条 warning。
代价是同一件事有两处剥离逻辑。用同一个模块级 helper `_strip_dsn_driver(dsn)` 供两处调用,避免实现分叉。
## 6. 行为审计
| 现有行为 | 处置 |
|---|---|
| `from_env` 对上述 15 条的校验与报错 | **全部保留**,时机提前到 `cls(...)`;`_load_*` 内联检查删除,避免同一约束两处维护 |
| `_load_pg_dsn``+driver` | **保留**,继续只在 env 路径生效(§5) |
| `_load_choice``default` 语义(键缺失时取默认) | **保留**,那是 env 解析职责,不是不变量 |
| `_load_breaker` 的有效阈值派生 `max(配置值, 源级并发×2)` | **有意保留在 env 层**(verifier 二次核验点名,记此备案免成"第五批")。它是**派生**不是校验/规范化:两路产出确实不同(env 装配 threshold=5/并发=100 得 200,直接构造得 5),但派生依赖的是"用户没显式表态时库替他选一个合理值"的 env 语义;代码构造那条路,调用方给什么就是什么表态。其跨字段下限风险由 `_validate_probe` 在构造期兜底 |
| 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` |
| `client.py` 5 处 assert | **保留**,仅改注释(§4) |
| 异常类型 | 一律 `ValueError`,与第一轮及既有装配错误一致 |
**有意放弃**:不校验 `pricing_path` 指向的文件是否存在(I/O 不属于配置校验,`PricingTable.from_file` 自会报错);不强制 `cache_backend == "none"` 时 namespace/ttl 必须为 None(多余字段无害)。
## 7. 非功能维度
与第一轮同构,不重复论证:`__post_init__` 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;`__post_init__` 不改字段故幂等。**性能**:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 `GatewaySettings`,可忽略。
## 8. 测试策略
`tests/unit/test_config.py::TestCrossFieldInvariants` 扩充(不新建类,同族不变量归一处):
| 用例组 | 断言 |
|---|---|
| 6 条枚举各一条非法值 | 抛 `ValueError`,消息含字段名与合法域 |
| redis_url 三条件(limiter/breaker/cache 各一) | 抛 `ValueError`,消息点明需要 `redis_url` |
| cache namespace 缺失 / ttl ≤ 0 | 抛 `ValueError` |
| telemetry sqlite path / pg dsn 缺失 | 抛 `ValueError` |
| `structured_max_retries=-1``scope=""` | 抛 `ValueError` |
| pg dsn 含 `+asyncpg`(直接构造) | 后缀被剥,字段值为干净 DSN,且发出一条 warning(用 `caplog`/loguru sink 断言) |
| pg dsn 干净(直接构造)、或经 `from_env` 传入 | **不发** warning——env 路已在 `_load_pg_dsn` 剥过,不该刷噪音 |
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
| **回归护栏**:`GatewayClient.from_settings` 走 redis 三后端的合法配置 | 装配成功,证明 assert 前提真的被保证了 |
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
版本:**1.0.2**(patch),CHANGELOG 同样单列"行为收紧"小节。
## 9. 人类拍板结论(2026-07-30)
| 问题 | 结论 |
|---|---|
| §5 DSN 处置 | **选 C**:构造期剥后缀 + `logger.warning`。不静默改用户的值,也不让两条装配路产出不一致 |
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
| 版本 | 1.0.2(patch) |
| **范围追加**(实施中经 verifier 发现后拍板) | G1-G4 四条同族遗漏一并纳入本轮;G1 的 scope 规范化取**静默**小写+strip(不告警——`from_env` 一直静默小写,scope 大小写不承载语义) |
## 10. 实施留痕
分支 `fix/settings-invariants-round-2`。TDD 两段:主体 15 条先 **16 failed**、G1-G4 追加 **9 failed**,实现后全绿(547 passed / 14 skipped,1.0.1 基线 516)。
### 10.1 独立 verifier 的关键发现
第一次核验判**有阻塞**,已修:
- **阻塞(本轮新引入)**:DSN 剥离的 warning 打印了完整连接串,**含明文密码**,而库内此前从无任何地方打印连接串——违反 P5。已改为只报 scheme 段变化,并补回归测试断言密码与 host/path 不进日志。
- **变异测试 27/28 被杀**,唯一存活的是 `_load_pgw``PGW_CACHE_BACKEND` 域检查删掉后仍全绿(该 env 层 raise 零覆盖)。已补 `test_cache_backend_whitelist`,与既有 `test_telemetry_backend_whitelist` 对称。
- **assert 处置经独立核验成立**:遍历所有可达构造路径均无法制造 assert 失败,`python -O` 下同样在构造期被拦(旧病症消失);唯一能触发的是 `object.__new__` 绕过 `__post_init__` 的人造路径,非公共 API。
- **frozen 语义无副作用**:`object.__setattr__``hash`/相等性/集合去重正常,`replace` 幂等不重复告警,`pickle`/`deepcopy` 不触发 `__post_init__` 故不重复告警,对外仍抛 `FrozenInstanceError`
### 10.2 G1-G4:第三批遗漏(已纳入本轮)
verifier 通读 `_load_*` 后发现,除设计 §1 的 15 条外还有四条**规范化**只在 env 路生效——与本轮所修的 DSN 是同一类:
| | 内容 | 危害 |
|---|---|---|
| G1 | `scope` 小写化 | **最严重**:scope 进 Redis key,大小写不一致使限流/熔断状态分裂到两套命名空间,分布式治理静默失效 |
| G2 | `redis_url` 空串归 None | 空串骗过 `is None`,退化为 redis 客户端的连接串天书报错——正是本轮 CHANGELOG 声称已消除的那种 |
| G3 | `pricing_path` 空串归 None | 退化为 `Is a directory: '.'` |
| G4 | `EmbeddingSettings.batch_size`/`expected_dim` 域 | 该类无 `__post_init__`;晚一步到 client 构造才 fail-loud |
统一收进新增的 `GatewaySettings._normalize()`(在全部 `_validate_*` 之前跑)与 `EmbeddingSettings.__post_init__`。DSN 后缀因需看 backend 且需告警,规范化留在 `_validate_telemetry`
@@ -0,0 +1,23 @@
---
type: design
node_id: design:est-tokens-decoupling
title: "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)"
date: 2026-07-30
---
# est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)
全文见 [2026-07-30-est-tokens-decoupling-design.md](2026-07-30-est-tokens-decoupling-design.md)。缘起是 Gitea issue #2(下游 CHSAnalyzer 提出)。
- **缘起**: `SourceConfig.est_tokens` 被派了两份对"保守"定义相反的差事——TPM 入场预扣(多押金 = 安全)与 usage 帧缺失时的遥测用量兜底(计费没有安全方向)。CHS `config.py:55` 自己就把它定义为"须 ≥ 最坏情形 token"的**上界**,而库把遥测拆 `prompt`/`completion` 两列后又将整个估值塞进单价更贵的 `completion`(`openai_compat.py:146`),形成双重系统性高估:实测算例 26 倍。
- **第二个症状**: `types.py:125``tpm > 0 ⇒ est_tokens > 0` 把供应商配额(可从配额页抄)与库的实现细节(无人能正确取值)绑死,下游删掉猜测项后 `tpm` 只能填 0,被迫在自己配置模型里加校验绕开。
- **方案(两个决策点,人类审批)**: ① usage 不可得时记 `0/0` + `usage_source` 新增 `unavailable` + cost 记 NULL;② `est_tokens` 未填时由库派生 `tpm // 60`,字段降为可选调优覆盖(不删不改名,迁移兼容)。
- **值域三态各有生产者**: `measured`(正常)、`estimated`(打捞路径——收到 usage 帧但流被截断,数字真实而可信度降级)、`unavailable`(用量不可得)。故 `estimated` 不是空值域,历史行亦读兼容。
- **被否决 · usage 兜底记 0 但沿用 `estimated`**: cost 会算出 `0.0`,"免费"与"未知"在数据上不可区分,账目缺口无法量化。
- **被否决 · 保留 est 兜底只修 prompt/completion 分配比例**: 比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"的根因,仍高估约 9 倍。
- **被否决 · 固定默认常量(如 1000)**: 与配额规模无关,在途上限随规模乱飘(`tpm=6000` 只剩 6 个在途、`tpm=600000` 放行 600 个)。派生值尺度无关且语义可文档化("一次调用约占一秒钟的配额份额")。
- **被否决 · issue 原建议的遥测 p90 自估**: `TelemetryRecorder` 是纯只写端口,自估需新增读接口并强制所有后端(含 `none`)实现,公共 API 扩张远大于它要省掉的一个可选字段,且无实测证据表明派生默认值不够用。**注**: 初稿曾以"把两条方向相反的降级铁律焊在一起"为主论据,经独立审查撤回——p90 可在遥测读失败时回退纯派生值,限流侧仍能 fail-closed。
- **被否决 · 派生逻辑取全局与单源 tpm 的较紧者**: 需改三处 `QuotaGate` 装配,且它修的是一个**既有**缺口(单源 `tpm=0` 而全局 `tpm>0` 时预扣为 0),属任务外,建议另开 issue。
- **有意放弃的迁移保留项**: `migrations/chsanalyzer.md:151` 曾把"usage 缺失按 est 估算"列为保留(理由"保守计量")。本设计推翻:CHS 只记单个 `total_tokens` 不存在分配问题,而"保守"在计费语境只有错误一个方向。反静默的原始意图仍保留——被放弃的只是"编一个数字"这个手段。
- **独立审查(两轮)抓出的两处实质缺陷**: ① 只改失败侧结算不够,`retry.py:338`/`embedding.py:271` 的**成功侧**取自同一返回值,改前 `actual` 恰等于预扣量使 `delta==0`,不同改则成功调用押金被整笔退回,对"从不回 usage 帧的网关源"构成系统性 TPM 失效;② OCR 行原判为"假陈述"是错的——`types.py:51``ocr.py:9` 明示 OCR 的 0 token 属**事实**,且改标 `unavailable` 会灌水本方案赖以成立的缺口度量,已剔出。
- **待办**: 经 `writing-plans` 出实施计划;发版须同步 CHANGELOG 与 wiki(缺口查询口径须带 `AND cache_hit = false`),并回帖 issue #2
@@ -0,0 +1,17 @@
---
type: design
node_id: design:settings-invariants-round-2
title: "GatewaySettings 装配校验补齐(第二轮)"
date: 2026-07-30
---
# GatewaySettings 装配校验补齐(第二轮)
全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。
- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。
- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。
- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。
- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。
- **本轮唯一新决策**: `_load_pg_dsn``+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。
- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。
+29
View File
@@ -95,6 +95,21 @@
"id": "design:settings-invariant-guards", "id": "design:settings-invariant-guards",
"label": "GatewaySettings 跨字段不变量守卫的生效范围", "label": "GatewaySettings 跨字段不变量守卫的生效范围",
"type": "design" "type": "design"
},
{
"id": "design:settings-invariants-round-2",
"label": "GatewaySettings 装配校验补齐(第二轮)",
"type": "design"
},
{
"id": "design:est-tokens-decoupling",
"label": "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)",
"type": "design"
},
{
"id": "plan:est-tokens-decoupling",
"label": "est_tokens 解耦实施计划",
"type": "plan"
} }
], ],
"links": [ "links": [
@@ -160,6 +175,20 @@
"relation": "implements", "relation": "implements",
"evidence": "T0-T14 逐节实现设计 §4-§10/§15", "evidence": "T0-T14 逐节实现设计 §4-§10/§15",
"added": "2026-07-22T09:33:05.357964+00:00" "added": "2026-07-22T09:33:05.357964+00:00"
},
{
"source": "design:est-tokens-decoupling",
"target": "design:m1-core-design",
"relation": "refines",
"evidence": "精化 M1 冻结的 est_tokens 双职责语义: 保留 TPM 预扣、推翻 usage 缺失按 est 兜底(m1-core-design.md:59,222),改记 0/0 + unavailable + cost NULL",
"added": "2026-07-30T09:33:55.383401+00:00"
},
{
"source": "plan:est-tokens-decoupling",
"target": "design:est-tokens-decoupling",
"relation": "implements",
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
"added": "2026-07-30T09:39:26.442986+00:00"
} }
] ]
} }
+9 -3
View File
@@ -1,14 +1,18 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-07-30 03:44 UTC > 自动生成,更新时间:2026-07-30 09:39 UTC
## design (12) ## design (16)
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design` - [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design` - [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design` - [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
@@ -29,12 +33,14 @@
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline` - [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
## plan (10) ## plan (12)
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan` - [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan` - [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience` - [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
+9
View File
@@ -48,3 +48,12 @@
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面 - [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards) - [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面 - [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2)
- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面
- [2026-07-30 09:32 UTC] 新增 design: est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2) (design:est-tokens-decoupling)
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
- [2026-07-30 09:33 UTC] 新增边: design:est-tokens-decoupling --refines--> design:m1-core-design
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
+3 -3
View File
@@ -92,7 +92,7 @@
| 项目键(.env.example 实测) | 库对应 | 差异 | | 项目键(.env.example 实测) | 库对应 | 差异 |
|---|---|---| |---|---|---|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 见 ⚠️ G2 | | `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 键保留不改名,但 2026-07-30 起由必填降为可选(G2 已闭,详见该行) |
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) | | `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) |
| `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 | | `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 |
| `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 | | `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 |
@@ -148,7 +148,7 @@ stack = ExtractionProviderStack(
| Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) | | Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) |
| 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) | | 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) |
| 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** | | 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** |
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **保留**(`usage_source` 已进 ARCH §5.1;依赖 G2) | | usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **有意放弃**(2026-07-30 推翻原"保留"判定,est_tokens 解耦设计 §4)。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆成两列后无法忠实分配,而 `est_tokens` 按 CHS 自身定义(config.py:55)是**最坏情形上界**——"保守"在限流语境安全(押多了只是慢),在计费语境只有虚高一个方向。库改为如实记 `0/0` + `usage_source="unavailable"` + cost NULL(ARCH §5.1)。**"不静默用 0"的原始意图完整保留**:被放弃的只是"编一个数字"这个手段,缺失依然有显式标注且可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
| reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) | | reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) |
| enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) | | enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) |
| 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) | | 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) |
@@ -182,7 +182,7 @@ stack = ExtractionProviderStack(
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 | | R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 | | R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 | | **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
| **G2** | ⚠️ `est_tokens`(TPM 预扣常量 + usage 缺失兜底,config.py:55)不在 ARCH §7.7 SourceConfig 字段清单;§7.3 `try_acquire(source, est_tokens)` 的 est 来源未定义 | M2 | **架构缺口**,修订 §7.7 | | **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 | | **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 | | **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
| **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 | | **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 |
+1 -1
View File
@@ -130,7 +130,7 @@ loop = AgentLoop(client, max_steps=...,
| B10 | 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) | **保留**(库同款语义) | | B10 | 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) | **保留**(库同款语义) |
| B11 | SSE 流提前断开且未见 `[DONE]` 时正常返回:`usage_sink["done"]` 写入后无人检查(client.py:115-117),截断响应被当成功**并写入缓存** | **修复**: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存 | | B11 | SSE 流提前断开且未见 `[DONE]` 时正常返回:`usage_sink["done"]` 写入后无人检查(client.py:115-117),截断响应被当成功**并写入缓存** | **修复**: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存 |
| B12 | provider 差异靠字符串猜: `"deepseek" in provider`/`"qwen" in provider` 注入 thinking 参数(client.py:139-144)、`<think>` 剥离(client.py:348) | **替换**: provider 注册表(D11) | | B12 | provider 差异靠字符串猜: `"deepseek" in provider`/`"qwen" in provider` 注入 thinking 参数(client.py:139-144)、`<think>` 剥离(client.py:348) | **替换**: provider 注册表(D11) |
| B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source=measured/estimated` | | B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source` 三态 `measured/estimated/unavailable`(2026-07-30 est_tokens 解耦后由两态扩为三态)。GovDoc 原行为"落 0 且无标注"中的落 0 反而与库一致,被升级的是**标注**:缺失行记 `unavailable` 且 cost 为 NULL,缺口可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
| B14 | 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) | **升级**: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移 | | B14 | 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) | **升级**: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移 |
| B15 | 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) | **有意保留**(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效 | | B15 | 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) | **有意保留**(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效 |
| B16 | `CancelledError` 穿透重试循环(client.py:396 `except Exception` 天然放行),取消的调用**不记遥测** | **保留**穿透;取消是否记遥测库未定义,见 §9-R6 | | B16 | `CancelledError` 穿透重试循环(client.py:396 `except Exception` 天然放行),取消的调用**不记遥测** | **保留**穿透;取消是否记遥测库未定义,见 §9-R6 |
@@ -0,0 +1,227 @@
# est_tokens 解耦实施计划
- **目标**: 把 `SourceConfig.est_tokens` 的两个职责(TPM 入场预扣 / usage 缺失时的遥测用量兜底)拆开,并解绑 `tpm > 0 ⇒ est_tokens > 0` 装配约束。
- **方案概述**: usage 不可得时遥测记 `0/0` 并标新值 `unavailable`、cost 落 NULL(不再拿限流押金编计费数字);`est_tokens` 未填时由库按 `tpm // 60` 派生预扣量,字段降为可选调优覆盖(保留不删不改名)。全部依据已批准设计 `research-wiki/designs/2026-07-30-est-tokens-decoupling-design.md`,其 §3.2 的 11 条改动项已钉死行号。
- **涉及技术**: Python 3.11 frozen dataclass、pytest(含 `tests/contracts` 双后端契约测试)、真实 Redis(integration/contracts)、pydantic-settings 不涉及改动。
- **溯源**: Gitea issue #2;wiki 实体 `design:est-tokens-decoupling`
## 1. 任务排序的硬约束(先读这节再动手)
三处改动互相牵制,**顺序错了会引入押金泄漏或计费造假**,且中间态不报错、只静默偏差:
| 若单独先做 | 后果 |
|---|---|
| 先改 usage 兜底为 `(0, 0)`,结算点还没切派生值 | 成功调用 `actual = 0` 而入场押了 `est_tokens`,`delta` 为负 → **押金整笔退回**,TPM 闸退化成进门即放行 |
| 先切入场(`ratelimit.py:26`)+ 解绑约束,而结算点还没切 | `est_tokens=0` 的源入场押派生值、结算退 0 → 同样泄漏。**注意机制**:若**只**解绑约束而 `ratelimit.py` 一行未动,后果不是泄漏而是入场**完全不预扣**(仍传 `est_tokens=0`)——那是设计 §2.2 已否决的"不预扣"退化形态。两者都要避免,故 T4 必须在 T2 之后 |
| 先改 `openai_compat.py:176``_merge` 还是二值逻辑 | embedding 的 `unavailable` 批被 `any(== "estimated")` 判 False 从而**误标 `measured`**,cost 照算 |
因此排序为:**先加能力(零行为变更)→ 再把所有调用点切到新能力(此时等价,因显式值优先)→ 再让三态生效 → 最后解绑约束**。Task 1-2 完成后行为逐字不变,Task 3 才是行为变更主体,Task 4 才让派生值真正启用。**不要合并或调换 Task 2 / 3 / 4 的顺序。**
## 2. 文件结构
| 文件 | 职责 | 涉及任务 |
|---|---|---|
| `src/polygateway/types.py` | 新增 `SourceConfig.effective_est_tokens()` 派生方法与 `usage_source` 三态值域常量;删除 `_validate_gates` 里的条件必填;修 `EmbeddingTransportResult` 行内注释 | T1、T3、T4 |
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate.try_acquire` 入场预扣改用派生值 | T2 |
| `src/polygateway/middleware/retry.py` | 成功侧(`:338`)与失败侧(`:370`)结算改用派生值 | T2 |
| `src/polygateway/embedding.py` | 同上(`:271`/`:294`);`_merge` 三态合并;`_total_cost` 存在不可得批时整体 NULL | T2、T3 |
| `src/polygateway/transports/openai_compat.py` | 两处 usage 兜底改 `unavailable`;打捞覆盖加前置条件 | T3 |
| `src/polygateway/middleware/telemetry.py` | cost 短路(插在 `cache_hit` 之后);失败尝试与终态失败改标 `unavailable` | T3 |
| `src/polygateway/ocr.py` | **不改**(设计 §3.3 已剔出),仅加防回归测试 | T3 |
| `research-wiki/ARCHITECTURE.md` | §7.7 行 428、§5.1 行 331、§4.4 行 305、§7.1 行 384 | T5 |
| `research-wiki/migrations/chsanalyzer.md` | 行 151 保留项改判为有意放弃;G2(行 185)标注已解决 | T5 |
| `.env.example` | 行 11 删除"TPM > 0 时 EST_TOKENS 必填 > 0" | T5 |
| `CHANGELOG.md` | 行为变更小节(值域新增 + cost 口径 + 约束解绑) | T5 |
测试文件:`tests/unit/test_types.py``test_retry.py``test_openai_compat.py``test_telemetry.py``test_embedding.py``test_ocr_client.py``tests/contracts/test_limiter_contract.py`
## 3. 保真校验
本计划**不新增**任何 `reference/` 移植代码,但触及 ARCHITECTURE §1.4 关键资产索引中的"遥测口径"与"限流结算",且**有意推翻**一条已声明保留的迁移行为(CHS `invokers.py:241-254` 的"usage 缺失按 est 估算",见设计 §4)。故设以下检查点,每个任务完成时逐条确认:
1. `Permit.settle()` 的"多退少补 + 幂等 flag"语义不得改变;`release()` 的 finally 必然执行不得改变。
2. **不得触碰** `backends/redis/limiter.py` 的 Lua 脚本与 `backends/memory/limiter.py` 的窗口/租约算法——本计划只改传入 `try_acquire` 的**数值来源**,不改闸门算法。
3. 不得改变 `errors.py` 四分类归属,不得新增运行时异常类型;值域违反不走异常路径(设计 §3.1 已裁决)。
4. `ocr.py``settle` 恒 0 与 `usage_source="measured"` 保持不变。
5. 遥测 18 字段冻结不变、无 DDL 变更(两 schema 的 `cost` 列已可空)。
## 4. 任务清单
### T1 — 加派生能力与值域常量(零行为变更)
- [x] **改** `src/polygateway/types.py`
新增模块级值域常量与 `SourceConfig` 方法。派生按**源自身 tpm**,全局 tpm 不参与(设计 §7 已声明为既有限制、本次不修):
```python
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
_EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
```
```python
def effective_est_tokens(self) -> int:
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
if self.est_tokens > 0:
return self.est_tokens
if self.tpm > 0:
return max(1, self.tpm // _EST_TOKENS_QUOTA_DIVISOR)
return 0
```
**验收标准**: 方法存在且为纯函数(不读全局、不 await);此任务**不修改任何调用点**,全库行为逐字不变。
**测试要求**(先失败后通过:方法不存在时 `AttributeError`)——`tests/unit/test_types.py`:
- `tpm=6000, est_tokens=0``100`;`tpm=600000, est_tokens=0``10000`(尺度无关:两者在途上限同为 60)
- `tpm=30, est_tokens=0``1`(下界不塌到 0)
- `tpm=0, est_tokens=0``0`(TPM 闸未启用,不预扣)
- `tpm=6000, est_tokens=4000``4000`(显式值优先于派生)
- `USAGE_SOURCES` 恰为三元集合
**值域封闭的两条实质断言**(设计 §6 要求;缺了它们 `USAGE_SOURCES` 会沦为零消费者的死常量,且 §3.1 的落点裁决无回归保护):
- **生产侧封闭**: 参数化覆盖库内全部 `usage_source` 生产点(`_resolve_usage``_resolve_embedding_usage``_merge``TelemetryEmitter.emit_*`),断言产出恒 ∈ `USAGE_SOURCES`。此断言在 T1 阶段即可写(此时产出仅 `measured`/`estimated`),T3 完成后自动覆盖 `unavailable`
- **不做运行时校验**: `LLMResponse(usage_source="garbage")` 构造**不抛异常**——锁定设计 §3.1 的裁决(公共 frozen dataclass 不加 `__post_init__` 值域校验,否则裸 `ValueError` 不属四分类、会逃出 `chat()`)。没有这条,后人很容易顺手补上校验而击穿 `chat()`
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
### T2 — 五个调用点切到派生值(零行为变更)
此时 `est_tokens > 0` 仍是必填(约束未解绑),故 `effective_est_tokens()` 恒返回显式值,**行为与改前逐字相同**。这一步只是把数值来源换掉,为 T3 铺路。
- [x] **改** `src/polygateway/middleware/ratelimit.py:26``source.est_tokens``source.effective_est_tokens()`
- [x] **改** `src/polygateway/middleware/retry.py:370`(失败侧,`if not dead` 分支内)→ `source.effective_est_tokens()`
- [x] **改** `src/polygateway/embedding.py:294`(失败侧)→ 同上
- [x] **改** `src/polygateway/middleware/retry.py:338`(成功侧)— 加不可得分支:
```python
if result.usage_source == "unavailable":
actual = source.effective_est_tokens()
else:
actual = result.prompt_tokens + result.completion_tokens
```
- [x] **改** `src/polygateway/embedding.py:271`(成功侧)— 同构,`actual = result.prompt_tokens` 落在 else 分支。
`TransportResult.usage_source`(`types.py:75`)与 `EmbeddingTransportResult.usage_source`(`types.py:273`)均为必填字段,在这两处的 `result` 局部变量上直接可读。
**验收标准**: 五处均不再直接读 `source.est_tokens`;新分支在本任务中**永不触发**(尚无 `unavailable` 生产者),现有测试全绿即证明零行为变更。**不要**在本任务修改 `retry.py:329``actual = 0` 初值,也不要动 `RequestRejectedError`/`ResultInvalidError`/`SourceDeadError` 三条失败分支(设计 §3.3:它们的 `actual` 停在 0 属既有行为)。
**测试要求**(回归保护,先失败后通过不适用于零行为变更任务,故以"现有测试不得回归 + 新增等价性断言"为门):
- 新增 `tests/unit/test_retry.py`:`tpm=1000, est_tokens=400` 的源在 usage 正常返回时,settle 收到的 `actual` 等于实测 token 之和(锁定 else 分支);现有 `test_settle_uses_actual_usage` 必须继续通过
- `tests/contracts/test_limiter_contract.py` 全绿(双后端)
**验证**:
```
conda run --no-capture-output -n PolyGateway pytest tests/unit tests/contracts -v
```
→ 全 PASS。**Redis 契约测试用真实 Redis db3,不得与其他 Redis 测试并跑**(时序隔离)。
### T3 — 值域三态生效(行为变更主体)
- [x] **改** `src/polygateway/transports/openai_compat.py:146` — 兜底不再读 `est_tokens`:
```python
return 0, 0, "unavailable"
```
- [x] **改** `src/polygateway/transports/openai_compat.py:176` — embedding 兜底同理 `return 0, "unavailable"`
- [x] **改** `src/polygateway/transports/openai_compat.py:336` — 打捞覆盖加前置条件(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`):
```python
if salvaged and usage_source == "measured":
usage_source = "estimated" # 收到 usage 帧但流被截断: 数字真实、可信度降级
```
- [x] **改** `src/polygateway/middleware/telemetry.py:130-135` — cost 短路,**插在 `cache_hit` 分支之后**(缓存命中未产生新调用,`0.0` 是事实):
```python
if cache_hit:
cost: float | None = 0.0
elif usage_source == "unavailable":
cost = None # 用量不可得: 宁可算不出成本,也不算错成本
elif error is None and model and self._pricing is not None:
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
else:
cost = None
```
- [x] **改** `src/polygateway/middleware/telemetry.py:58``:100` — 失败尝试与终态失败的 `usage_source``"estimated"``"unavailable"`(cost 本已是 None,不改金额)
- [x] **改** `src/polygateway/embedding.py:383,390` — 二值合并扩三态(优先级:任一不可得 → 整体不可得):
```python
sources = {o.result.usage_source for o in outcomes}
if "unavailable" in sources:
merged_source = "unavailable"
elif "estimated" in sources:
merged_source = "estimated"
else:
merged_source = "measured"
```
- [x] **改** `src/polygateway/embedding.py:397` `_total_cost` — 存在 `unavailable` 批时整体返回 `None`(逐批求和会给出偏低却看似有效的金额)
- [x] **改** `src/polygateway/types.py:273` — 行内注释 `# measured | estimated` → 三态(内核里不留矛盾注释)
- [x] **改** `src/polygateway/transports/openai_compat.py:142``:172` — 两个函数的中文 docstring 仍写着"缺失/非法按 `est_tokens` 保守兜底并标 `estimated`",改完不改就留下两句主动陈述旧行为的文档(与 `types.py:273` 同一把尺子)
**验收标准**: 全库不再有任何位置把 `est_tokens` 写进遥测用量;`ocr.py` 一字未动。
**测试要求**(先失败后通过):
- `test_openai_compat.py`:`est_tokens=4000` + usage 帧缺失 → `(0, 0, "unavailable")`(**改前返回 `(0, 4000, "estimated")`,故先失败**;现有用例 `test_usage_missing_falls_back_to_est` 需改写)
- **改写** `tests/unit/test_embedding.py:105 test_missing_usage_falls_back_estimated` — 现断言 `prompt_tokens == 7 and usage_source == "estimated"`(夹具 `est_tokens=7`),改 `openai_compat.py:176` 后必然变红,须改为 `(0, "unavailable")`。这是一条**位于 `test_embedding.py` 里的 transport 级用例**,容易在只盯 `test_openai_compat.py` 时漏掉
- `test_openai_compat.py`:打捞 + usage 帧存在 → `estimated` **且 cost 非 None**;打捞 + usage 缺失 → `unavailable` **且 cost 为 None**。cost 配套断言不可省——#4 的真正目的就是防 `0/0` 被算成假的 `0.0`,只断言 `usage_source` 钉不住它
- `test_telemetry.py`:成功行 `usage_source="unavailable"``record_llm_call` 收到 `cost=None`(改前按 4000×输出单价算出 `0.032`)
- `test_telemetry.py`:`cache_hit=True``unavailable` → cost 仍为 `0.0`(锁定分支次序)
- `test_telemetry.py`:失败尝试与终态失败行 `usage_source == "unavailable"`
- `test_embedding.py`:混合批 `measured + unavailable` → 整体 `unavailable``cost is None`(改前误标 `measured`)
- `test_ocr_client.py`:OCR 成功行仍为 `measured` 且 settle 恒 0(**防回归**,锁定设计 §3.3 的剔出决定)
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit -v` → 全 PASS;`conda run --no-capture-output -n PolyGateway pytest tests/contracts -v` → 全 PASS(T2 的结算分支此时首次被激活,契约测试须复跑)
### T4 — 解绑装配约束(派生值真正启用)
- [x] **改** `src/polygateway/types.py:125-126` — 删除:
```python
if self.tpm > 0 and self.est_tokens <= 0:
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)")
```
`_validate_gates` 的其余部分(`timeout_s > 0`、四个限额非负)**保留不动**。`est_tokens` 字段本身与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键保留不删不改名(迁移兼容硬约束);`config.py:40` 的键映射无需改动。
**验收标准**: `tpm=6000, est_tokens=0` 可构造;该源入场预扣 100,**成功侧与非 dead 瞬时失败侧**按 100 结算(delta=0)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧维持既有的 `actual=0` 全额退回**——`retry.py:355-359` 的取消分支不给 `actual` 赋值、停在 `:329` 初值,这是设计 §3.3 声明不动的既有行为,**不要**为了凑"三侧一致"去改它。
**测试要求**(先失败后通过:改前构造即抛 `ValueError`):
- **改写** `tests/unit/test_types.py:94 test_tpm_requires_est_tokens` — 它现在断言 `_make_source(tpm=10000, est_tokens=0)``ValueError`,删约束后必然变红。保留后半条正向断言(`est_tokens=800` 仍原样返回),把前半条改为"构造成功且 `effective_est_tokens()` 返回派生值"
- `test_retry.py`:**成功侧**——未填 `est_tokens``tpm>0`、usage 帧缺失的成功调用后,TPM 窗口残留量等于派生预扣量而非 0(**这是设计中最易漏的一条**,回归 §3.2 #9;在 `test_retry.py:149``_src("a", tpm=1000, est_tokens=400)` 旁加 `est_tokens=0` 用例)
- `test_retry.py`:**失败侧**——同配置的非 dead 瞬时失败调用后,窗口残留量同为派生预扣量(回归 §3.2 #8)
- `tests/contracts/test_limiter_contract.py`:**只加后端级断言**——传入派生值时双后端的结算口径一致。**不要**在契约文件里写端到端用例:该文件直接驱动 limiter(形如 `limiter.try_acquire("s1", 0)`),不经 `QuotaGate`/`RetryMW`,照字面写会产出 `try_acquire(src.effective_est_tokens())` + `settle(同值)` 的退化用例——只测了后端算术,没测调用点是否真的切了派生值。上面两条端到端断言的载体是 `retry.py`,放 `test_retry.py`(内存后端)
**验证**: `make ci`(即 check + test,含 import-linter 契约)→ 全 PASS。**不要**在外层再套 `conda run`:`Makefile``check`/`test` 目标内部已各自 `conda run -n $(ENV)`,嵌套后外层的 `--no-capture-output` 也管不到内层缓冲
### T5 — 权威文档与发布物同步
- [x] **改** `research-wiki/ARCHITECTURE.md` 四处:§7.7 行 428(`est_tokens` 描述:可选调优覆盖 + 派生规则,删去"亦作 usage 缺失时的保守兜底")、§5.1 行 331(`usage_source` 三态 + cost NULL 口径)、§4.4 行 305("token 按 `est_tokens` 预扣" → 按有效预扣量)、§7.1 行 384(打捞路径强制 `estimated` → 仅在收到 usage 帧时降级)
- [x] **改** `research-wiki/migrations/chsanalyzer.md`:行 151 由"保留"改判"**有意放弃**"并写入设计 §4 的理由(CHS 只记单个 `total_tokens` 不存在分配问题;保守在计费语境无安全方向);G2(行 185)标注已由本设计解决
- [x] **改** `.env.example` 行 11:删除"TPM > 0 时 EST_TOKENS 必填 > 0",改注为"可选;未填则库按 tpm 派生"
- [x] **改** `CHANGELOG.md`:新增"行为收紧/变更"小节三条——`usage_source` 新增 `unavailable`、用量不可得行 cost 由数值变 NULL、`est_tokens` 降为可选
- [x] **改** wiki 用户文档站(按 `docs-convention.md` §2):usage/成本口径说明须写明缺口查询为 `WHERE usage_source='unavailable' AND cache_hit = false`(**必须带 `cache_hit` 限定**:缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高)
- [x] **回帖** Gitea issue #2:结论与下游可删绕行校验的时点
**验收标准**: 全库 grep `EST_TOKENS 必填``est_tokens` 兜底相关表述无残留;ARCHITECTURE.md 无自相矛盾表述。
**测试要求**: 纯文档,无行为测试。以 `grep` 输出为验收证据。
**验证**: `make ci` → PASS;`grep -rn "EST_TOKENS 必填" . --exclude-dir=.git` → 无输出
## 5. 完成判定
- [x] T1-T5 全部 checkbox 勾选,每个任务一次语义化提交(`commit` skill)
- [x] `make ci` 全绿(含 ruff、import-linter 洋葱契约、pytest 覆盖率)
- [x] 设计 §6 测试表的 10 行断言全部有对应测试且可出示"先失败后通过"证据(T2 的零行为变更任务以"现有测试不回归 + 等价性断言"替代)
- [x] 派新上下文 verifier subagent 独立验证(`verification-before-completion`,里程碑级/跨多文件硬门)
- [x] 版本 bump 与 CHANGELOG 同步发布(不得裸发)
## 6. 明确不做
派生值取全局与单源 tpm 较紧者(需改三处 `QuotaGate` 装配,修的是既有缺口,设计 §7 已声明另开 issue);遥测驱动的 p90 自适应预估(设计 §5 已否决,待实测证据);`ocr.py` 的 usage 标记(设计 §3.3 已剔出);`retry.py` 另外三条失败分支的 `actual` 初值。
@@ -0,0 +1,17 @@
---
type: plan
node_id: plan:est-tokens-decoupling
title: "est_tokens 解耦实施计划"
date: 2026-07-30
---
# est_tokens 解耦实施计划
全文见 [2026-07-30-est-tokens-decoupling-plan.md](2026-07-30-est-tokens-decoupling-plan.md)。实现设计 [est-tokens-decoupling](../designs/est-tokens-decoupling.md)。
- **5 个任务**: T1 加派生能力与三态值域常量(零行为变更)→ T2 五个入场/结算点切到派生值(零行为变更,因显式值优先)→ T3 值域三态生效(行为变更主体)→ T4 解绑 `tpm > 0 ⇒ est_tokens > 0`(派生值真正启用)→ T5 权威文档、CHANGELOG、wiki 与 issue 回帖。
- **排序是硬约束,不可调换**: 三处改动互相牵制且中间态**静默偏差、不报错**。先改 usage 兜底为 `(0,0)` 而结算点未切派生值 → 成功调用押金整笔退回(TPM 闸退化成进门即放行);先解绑约束而结算点未切 → 同样泄漏;先改 `openai_compat.py:176``_merge` 仍是二值 `any(=="estimated")``unavailable` 批被误标 `measured` 且 cost 照算。
- **T2 的等价性是安全阀**: 约束未解绑时 `effective_est_tokens()` 恒返回显式值,故 T1/T2 后行为逐字不变,现有测试全绿即为证明;T3 才是唯一的行为变更点。
- **保真校验(不新增移植,但触及关键资产)**: 不得改 Redis Lua 与内存后端的窗口/租约算法(只改传入 `try_acquire` 的数值来源)、不得改 `settle` 多退少补与幂等语义、不得改错误四分类归属、`ocr.py` 一字不动、遥测 18 字段冻结且无 DDL。
- **最易漏的测试**: T4 的"成功侧结算不退多"——未填 `est_tokens` 且 usage 帧缺失的**成功**调用后,TPM 窗口残留须等于派生预扣量而非 0。这正是独立审查在设计阶段抓出的缺陷,实施阶段必须有回归钉死。
- **发布口径**: 缺口度量必须写成 `WHERE usage_source='unavailable' AND cache_hit = false`——缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高。
+13 -3
View File
@@ -16,15 +16,25 @@ date: 2026-07-20
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) | | parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) | | model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
| messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) | | messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) |
| prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;缺失按 est 兜底 | | prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;缺失记 0/0(不编造估值,由 usage_source 标注) |
| usage_source | TEXT NOT NULL | measured / estimated | | usage_source | TEXT NOT NULL | measured / estimated / unavailable(2026-07-30 起三态,见下) |
| latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 | | latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 |
| ttft_ms / max_inter_token_ms | REAL | 流式活性测量 | | ttft_ms / max_inter_token_ms | REAL | 流式活性测量 |
| cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 | | cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 |
| error | TEXT | 异常信息;取消记 "cancelled" | | error | TEXT | 异常信息;取消记 "cancelled" |
| cost | REAL | M1 恒 NULL,M2 pricing 换算 | | cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 | | created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
## usage/成本口径(2026-07-30,est_tokens 解耦)
| usage_source | 含义 | 生产者 | cost |
|---|---|---|---|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
## 埋点位置(单一 helper 铁律) ## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+1 -1
View File
@@ -31,7 +31,7 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
__version__ = "1.0.1" __version__ = "1.0.3"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
+5 -5
View File
@@ -259,7 +259,7 @@ def _build_limiter(settings: GatewaySettings, sources: list[SourceConfig]) -> Ra
if settings.limiter_backend == "redis": if settings.limiter_backend == "redis":
from polygateway.backends.redis.limiter import RedisLimiter from polygateway.backends.redis.limiter import RedisLimiter
assert settings.redis_url is not None # 内部不变量: config 已校验 assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
return RedisLimiter.from_url( return RedisLimiter.from_url(
settings.redis_url, settings.redis_url,
scope=settings.scope, scope=settings.scope,
@@ -279,7 +279,7 @@ def _build_breaker(settings: GatewaySettings) -> ProviderGate:
if settings.breaker_backend == "redis": if settings.breaker_backend == "redis":
from polygateway.backends.redis.breaker import RedisGate from polygateway.backends.redis.breaker import RedisGate
assert settings.redis_url is not None # 内部不变量: config 已校验 assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope) return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
return InMemoryGate(config=settings.breaker) return InMemoryGate(config=settings.breaker)
@@ -299,7 +299,7 @@ def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
return InMemoryCache() return InMemoryCache()
from polygateway.backends.redis_cache import RedisCache from polygateway.backends.redis_cache import RedisCache
assert settings.redis_url is not None # 内部不变量: config 已校验 assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
return RedisCache.from_url(settings.redis_url) return RedisCache.from_url(settings.redis_url)
@@ -309,11 +309,11 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
if settings.telemetry_backend == "postgres": if settings.telemetry_backend == "postgres":
from polygateway.telemetry.postgres import PostgresRecorder from polygateway.telemetry.postgres import PostgresRecorder
assert settings.telemetry_pg_dsn is not None # 内部不变量: config 已校验 assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
return PostgresRecorder(settings.telemetry_pg_dsn) return PostgresRecorder(settings.telemetry_pg_dsn)
from polygateway.telemetry.sqlite import SQLiteRecorder from polygateway.telemetry.sqlite import SQLiteRecorder
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验 assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
return SQLiteRecorder(settings.telemetry_sqlite_path) return SQLiteRecorder(settings.telemetry_sqlite_path)
+114 -14
View File
@@ -16,6 +16,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dotenv import dotenv_values from dotenv import dotenv_values
from loguru import logger
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
@@ -47,6 +48,12 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"}) _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) _SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"}) _QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源) # 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
_DEFAULT_STALL_WINDOW_S = 300.0 _DEFAULT_STALL_WINDOW_S = 300.0
_DEFAULT_POLL_INTERVAL_S = 0.05 _DEFAULT_POLL_INTERVAL_S = 0.05
@@ -122,15 +129,94 @@ class GatewaySettings:
lease_ttl_s: float lease_ttl_s: float
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._validate_sources() self._normalize()
self._validate_identity()
self._validate_backends()
self._validate_cache()
self._validate_telemetry()
self._validate_lease() self._validate_lease()
self._validate_stall() self._validate_stall()
self._validate_probe() self._validate_probe()
def _validate_sources(self) -> None: def _normalize(self) -> None:
"""零源的配置装出来选源必然失败,构造期即拒。""" """把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:`/`pgw:gate:{scope}:`)
一个进程走 `from_env("LLM")` 拿到 "llm"另一个直接构造传 "LLM",同一逻辑
scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
抛连接串解析异常`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
规范化留在 `_validate_telemetry`
"""
normalized_scope = self.scope.strip().lower()
if normalized_scope != self.scope:
object.__setattr__(self, "scope", normalized_scope)
for field in ("redis_url", "pricing_path"):
if getattr(self, field) == "":
object.__setattr__(self, field, None)
def _validate_identity(self) -> None:
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
if not self.scope.strip():
raise ValueError("GatewaySettings.scope 不能为空")
if not self.sources: if not self.sources:
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源") raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
if self.structured_max_retries < 0:
raise ValueError(f"structured_max_retries 不能为负: {self.structured_max_retries}")
def _validate_backends(self) -> None:
"""后端选择必须落在合法域内,取 redis 的还必须有连接串。
域外取值此前只有 `from_env` 拦得住,直接构造会一路走到 `client.py`
`_build_*`,落进 else 分支静默不建后端,或撞上那里的断言
"""
for field, allowed in (
("limiter_backend", _LIMITER_BACKENDS),
("breaker_backend", _BREAKER_BACKENDS),
("cache_backend", _CACHE_BACKENDS),
("telemetry_backend", _TELEMETRY_BACKENDS),
("selector", _SELECTORS),
("quota_full", _QUOTA_FULL),
):
value = getattr(self, field)
if value not in allowed:
raise ValueError(f"{field} 非法值 {value!r};允许: {sorted(allowed)}")
on_redis = [f for f in _REDIS_DEPENDENT_BACKENDS if getattr(self, f) == "redis"]
if on_redis and self.redis_url is None:
raise ValueError(f"{''.join(on_redis)} 取 redis 时必须提供 redis_url")
def _validate_cache(self) -> None:
"""启用缓存必须有命名空间与正 TTL(缺命名空间即失去租户隔离,会毒化缓存)。"""
if self.cache_backend == "none":
return
if not self.cache_namespace:
raise ValueError("启用缓存时 cache_namespace 不能为空: 缓存 key 靠它做租户隔离")
if self.cache_ttl_s is None or self.cache_ttl_s <= 0:
raise ValueError(f"cache_ttl_s 必须 > 0(禁止永不过期): {self.cache_ttl_s}")
def _validate_telemetry(self) -> None:
"""遥测后端各自的落点必填;顺带剥掉 asyncpg 不认的 SQLAlchemy 驱动后缀。
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果但不静默`from_env`
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
他有权知道库动了他给的值
"""
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
if self.telemetry_backend != "postgres":
return
if not self.telemetry_pg_dsn:
raise ValueError("telemetry_backend=postgres 时必须提供 telemetry_pg_dsn")
stripped = _strip_dsn_driver(self.telemetry_pg_dsn)
if stripped != self.telemetry_pg_dsn:
# 只报 scheme 段: DSN 带密码,整串不得进日志(P5 敏感信息只走 .env)
logger.warning(
"telemetry_pg_dsn 的 scheme 含 asyncpg 不认的驱动后缀,已由 {} 剥为 {}",
self.telemetry_pg_dsn.partition("://")[0],
stripped.partition("://")[0],
)
object.__setattr__(self, "telemetry_pg_dsn", stripped)
def _validate_lease(self) -> None: def _validate_lease(self) -> None:
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。""" """调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
@@ -318,17 +404,15 @@ def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], defa
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]: def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
limiter_backend = _load_choice( # 合法域与构造期守卫共用常量;此处的检查保留是为了报错能点出 env 键名,
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory" # 构造期那道点的是字段名(两类调用方各看得懂自己那套)
) limiter_backend = _load_choice(env, "PGW_LIMITER_BACKEND", _LIMITER_BACKENDS, "memory")
breaker_backend = _load_choice( breaker_backend = _load_choice(env, "PGW_BREAKER_BACKEND", _BREAKER_BACKENDS, "memory")
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
)
_, cache_backend = _require(env, "PGW_CACHE_BACKEND") _, cache_backend = _require(env, "PGW_CACHE_BACKEND")
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND") _, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
if cache_backend not in ("redis", "memory", "none"): if cache_backend not in _CACHE_BACKENDS:
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}") raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
if telemetry_backend not in ("sqlite", "postgres", "none"): if telemetry_backend not in _TELEMETRY_BACKENDS:
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}") raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
redis_url = env.get("REDIS_URL") or None redis_url = env.get("REDIS_URL") or None
if "redis" in (limiter_backend, breaker_backend) and redis_url is None: if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
@@ -350,13 +434,22 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
} }
def _load_pg_dsn(env: Mapping[str, str]) -> str: def _strip_dsn_driver(dsn: str) -> str:
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)""" """剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回"""
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
scheme, sep, rest = dsn.partition("://") scheme, sep, rest = dsn.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}" return f"{scheme.partition('+')[0]}{sep}{rest}"
def _load_pg_dsn(env: Mapping[str, str]) -> str:
"""读取 Postgres DSN 并剥驱动后缀。
env 路在此剥干净,构造期那道就无事可做三项目 `.env` 里的 SQLAlchemy
写法不会每次装配都刷一条 warning
"""
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
return _strip_dsn_driver(dsn)
def _load_cache_keys( def _load_cache_keys(
env: Mapping[str, str], cache_backend: str, redis_url: str | None env: Mapping[str, str], cache_backend: str, redis_url: str | None
) -> dict[str, object]: ) -> dict[str, object]:
@@ -399,6 +492,13 @@ class EmbeddingSettings:
normalize: bool = False normalize: bool = False
expected_dim: int | None = None expected_dim: int | None = None
def __post_init__(self) -> None:
"""自身字段的域校验;内嵌的 gateway 由 `GatewaySettings.__post_init__` 自己把关。"""
if self.batch_size < 1:
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
if self.expected_dim is not None and self.expected_dim < 1:
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
@classmethod @classmethod
def from_env( def from_env(
cls, cls,
+22 -3
View File
@@ -268,6 +268,10 @@ class EmbeddingClient:
source_name=source.name, source_name=source.name,
operation="embedding", operation="embedding",
) )
if result.usage_source == "unavailable":
# 与 RetryMW 同口径: 用量不可得时按入场预扣量结算(设计 §3.2 #9)
actual = source.effective_est_tokens()
else:
actual = result.prompt_tokens actual = result.prompt_tokens
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
@@ -291,7 +295,8 @@ class EmbeddingClient:
reasons[source.name] = reason reasons[source.name] = reason
await self._record_quietly(self._breaker.record_failure(entry, reason, dead)) await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
if not dead: if not dead:
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款) # 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc) await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
return _FailedBatch(exc, immediate=dead) return _FailedBatch(exc, immediate=dead)
finally: finally:
@@ -380,14 +385,21 @@ class EmbeddingClient:
vectors = [_l2_normalize(v) for v in vectors] vectors = [_l2_normalize(v) for v in vectors]
first = outcomes[0] first = outcomes[0]
prompt_tokens = sum(o.result.prompt_tokens for o in outcomes) prompt_tokens = sum(o.result.prompt_tokens for o in outcomes)
estimated = any(o.result.usage_source == "estimated" for o in outcomes) # 三态合并优先级(解耦设计 §3.2 #10): 任一批不可得 → 整体不可得
sources = {o.result.usage_source for o in outcomes}
if "unavailable" in sources:
merged_source = "unavailable"
elif "estimated" in sources:
merged_source = "estimated"
else:
merged_source = "measured"
return EmbeddingResponse( return EmbeddingResponse(
vectors=vectors, vectors=vectors,
dim=first.result.dim, dim=first.result.dim,
model=first.source.model, model=first.source.model,
provider=first.source.provider, provider=first.source.provider,
prompt_tokens=prompt_tokens, prompt_tokens=prompt_tokens,
usage_source="estimated" if estimated else "measured", usage_source=merged_source,
latency_ms=sum(o.latency_ms for o in outcomes), latency_ms=sum(o.latency_ms for o in outcomes),
call_id=first.call_id, call_id=first.call_id,
source_name=first.source.name, source_name=first.source.name,
@@ -395,8 +407,15 @@ class EmbeddingClient:
) )
def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None: def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None:
"""全批成本;任一批用量不可得则整体记 NULL(解耦设计 §3.2 #11)。
逐批求和会把不可得的批当 0 计入,给出一个偏低却看似有效的金额
"宁可算不出成本,也不算错成本"的不变式相悖
"""
if self._pricing is None: if self._pricing is None:
return None return None
if any(o.result.usage_source == "unavailable" for o in outcomes):
return None
costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes] costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes]
known = [c for c in costs if c is not None] known = [c for c in costs if c is not None]
return sum(known) if known else None return sum(known) if known else None
+1 -1
View File
@@ -23,7 +23,7 @@ class QuotaGate:
async def try_acquire(self, source: SourceConfig) -> Permit | None: async def try_acquire(self, source: SourceConfig) -> Permit | None:
try: try:
return await self._limiter.try_acquire(source.name, source.est_tokens) return await self._limiter.try_acquire(source.name, source.effective_est_tokens())
except GovernanceBackendError: except GovernanceBackendError:
raise raise
except Exception as exc: except Exception as exc:
+7 -1
View File
@@ -335,6 +335,11 @@ class RetryMW:
overlay=request.overlay, overlay=request.overlay,
call_id=call_id, call_id=call_id,
) )
if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
actual = source.effective_est_tokens()
else:
actual = result.prompt_tokens + result.completion_tokens actual = result.prompt_tokens + result.completion_tokens
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
@@ -367,7 +372,8 @@ class RetryMW:
self._pacer.on_backpressure(source.name) self._pacer.on_backpressure(source.name)
await self._record_quietly(self._breaker.record_failure(entry, reason, dead)) await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
if not dead: if not dead:
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款) # 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit(request, source, call_id, started, error=exc) await self._emit(request, source, call_id, started, error=exc)
return _Failed(exc, immediate=dead) return _Failed(exc, immediate=dead)
finally: finally:
+7 -3
View File
@@ -44,7 +44,7 @@ class TelemetryEmitter:
response: LLMResponse | None, response: LLMResponse | None,
error: str | None, error: str | None,
) -> None: ) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试 usage 按 estimated 记 0""" """逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable"""
await self._record( await self._record(
request=request, request=request,
call_id=call_id, call_id=call_id,
@@ -55,7 +55,7 @@ class TelemetryEmitter:
thinking=response.thinking if response else "", thinking=response.thinking if response else "",
prompt_tokens=response.prompt_tokens if response else 0, prompt_tokens=response.prompt_tokens if response else 0,
completion_tokens=response.completion_tokens if response else 0, completion_tokens=response.completion_tokens if response else 0,
usage_source=response.usage_source if response else "estimated", usage_source=response.usage_source if response else "unavailable",
latency_ms=latency_ms, latency_ms=latency_ms,
ttft_ms=response.ttft_ms if response else None, ttft_ms=response.ttft_ms if response else None,
max_inter_token_ms=response.max_inter_token_ms if response else None, max_inter_token_ms=response.max_inter_token_ms if response else None,
@@ -97,7 +97,7 @@ class TelemetryEmitter:
thinking="", thinking="",
prompt_tokens=0, prompt_tokens=0,
completion_tokens=0, completion_tokens=0,
usage_source="estimated", usage_source="unavailable",
latency_ms=latency_ms, latency_ms=latency_ms,
ttft_ms=None, ttft_ms=None,
max_inter_token_ms=None, max_inter_token_ms=None,
@@ -129,6 +129,10 @@ class TelemetryEmitter:
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状) # 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
if cache_hit: if cache_hit:
cost: float | None = 0.0 cost: float | None = 0.0
elif usage_source == "unavailable":
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
cost = None
elif error is None and model and self._pricing is not None: elif error is None and model and self._pricing is not None:
cost = self._pricing.cost(model, prompt_tokens, completion_tokens) cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
else: else:
+28 -11
View File
@@ -138,12 +138,31 @@ def _strip_think(content: str) -> tuple[str, str]:
return _THINK_PATTERN.sub("", content).strip(), match.group(1).strip() return _THINK_PATTERN.sub("", content).strip(), match.group(1).strip()
def _resolve_usage(usage: dict[str, Any], source: SourceConfig) -> tuple[int, int, str]: def _resolve_usage(usage: dict[str, Any]) -> tuple[int, int, str]:
"""usage 帧读取;缺失/非法按 est_tokens 保守兜底并标 estimated(CHS invokers.py:241)。""" """usage 帧读取;缺失/非法记 0/0 并标 unavailable(est_tokens 解耦设计 §3.2 #3)。
不再拿 `est_tokens` 兜底: 它按 CHS 定义是"最坏情形上界",拿上界当实测值
只会系统性高估账单;宁可把用量记成显式的"不可得"(cost 随之为 NULL),
让缺口可被统计,也不编一个看似有效的数字用量口径自此不依赖源配置,
故不再收 `SourceConfig`
"""
prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens") prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens")
if isinstance(prompt, int) and isinstance(completion, int) and prompt + completion > 0: if isinstance(prompt, int) and isinstance(completion, int) and prompt + completion > 0:
return prompt, completion, "measured" return prompt, completion, "measured"
return 0, source.est_tokens, "estimated" return 0, 0, "unavailable"
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
前置条件不可省(解耦设计 §3.2 #4): usage 帧本就缺失时 `0/0` 会被洗成
`estimated`,进而按 token 换算出一个假的 `0.0` 成本
"""
prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {})
if salvaged and usage_source == "measured":
# 收到 usage 帧但流被截断: 数字真实、可信度降级(M1 设计 §6)
usage_source = "estimated"
return prompt, completion, usage_source
def _extract_vectors( def _extract_vectors(
@@ -168,12 +187,12 @@ def _extract_vectors(
return vectors return vectors
def _resolve_embedding_usage(data: dict[str, Any], source: SourceConfig) -> tuple[int, str]: def _resolve_embedding_usage(data: dict[str, Any]) -> tuple[int, str]:
"""usage 读取;缺失/非法按 est_tokens 保守兜底并标 estimated(与 chat 同口径)。""" """usage 读取;缺失/非法记 0 并标 unavailable(与 chat 同口径,设计 §3.2 #3)。"""
prompt = (data.get("usage") or {}).get("prompt_tokens") prompt = (data.get("usage") or {}).get("prompt_tokens")
if isinstance(prompt, int) and prompt > 0: if isinstance(prompt, int) and prompt > 0:
return prompt, "measured" return prompt, "measured"
return source.est_tokens, "estimated" return 0, "unavailable"
def _parse_embedding_payload( def _parse_embedding_payload(
@@ -186,7 +205,7 @@ def _parse_embedding_payload(
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ResultInvalidError(f"{source.name} embedding 响应非 JSON: {exc}", **ctx) from exc raise ResultInvalidError(f"{source.name} embedding 响应非 JSON: {exc}", **ctx) from exc
vectors = _extract_vectors(data, source, expected_count, ctx) vectors = _extract_vectors(data, source, expected_count, ctx)
prompt_tokens, usage_source = _resolve_embedding_usage(data, source) prompt_tokens, usage_source = _resolve_embedding_usage(data)
return EmbeddingTransportResult( return EmbeddingTransportResult(
vectors=vectors, vectors=vectors,
dim=len(vectors[0]), dim=len(vectors[0]),
@@ -331,9 +350,7 @@ class OpenAICompatTransport:
salvaged = self._check_done(sink, content_parts, thinking_parts, source) salvaged = self._check_done(sink, content_parts, thinking_parts, source)
content, thinking = self._finalize_text(content_parts, thinking_parts, profile) content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source) self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {}, source) prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
if salvaged:
usage_source = "estimated" # 打捞路径强制 estimated(设计 §6)
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
@@ -415,7 +432,7 @@ class OpenAICompatTransport:
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile [message.get("content") or ""], [message.get("reasoning_content") or ""], profile
) )
self._reject_empty_completion(content, source) self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}, source) prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
+17 -3
View File
@@ -9,6 +9,12 @@ from typing import Any
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) _MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
_EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
@@ -107,6 +113,14 @@ class SourceConfig:
self._validate_gates() self._validate_gates()
self._validate_watchdog() self._validate_watchdog()
def effective_est_tokens(self) -> int:
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
if self.est_tokens > 0:
return self.est_tokens
if self.tpm > 0:
return max(1, self.tpm // _EST_TOKENS_QUOTA_DIVISOR)
return 0
def _validate_identity(self) -> None: def _validate_identity(self) -> None:
for attr in ("name", "provider", "base_url", "api_key", "model"): for attr in ("name", "provider", "base_url", "api_key", "model"):
if not getattr(self, attr).strip(): if not getattr(self, attr).strip():
@@ -122,8 +136,8 @@ class SourceConfig:
for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"): for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"):
if getattr(self, attr) < 0: if getattr(self, attr) < 0:
raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)") raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)")
if self.tpm > 0 and self.est_tokens <= 0: # 注: 不再强制 `tpm > 0 ⇒ est_tokens > 0`——预扣量由 effective_est_tokens()
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)") # 自 tpm 派生,运维只需填供应商配额页上抄得到的 tpm(设计 §3.2 #1)
def _validate_watchdog(self) -> None: def _validate_watchdog(self) -> None:
# CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s # CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s
@@ -270,7 +284,7 @@ class EmbeddingTransportResult:
vectors: list[list[float]] vectors: list[list[float]]
dim: int dim: int
prompt_tokens: int prompt_tokens: int
usage_source: str # measured | estimated usage_source: str # measured | estimated | unavailable
raw: dict[str, Any] raw: dict[str, Any]
+17
View File
@@ -84,6 +84,23 @@ class TestTpmGate:
await p2.release() await p2.release()
assert (await limiter.source_stats("s1")).tpm_used == 600 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): async def test_failed_acquire_leaves_no_tpm_trace(self, limiter_factory):
src = make_source(tpm=500, est_tokens=400) src = make_source(tpm=500, est_tokens=400)
limiter = limiter_factory([src], _NO_GLOBAL) limiter = limiter_factory([src], _NO_GLOBAL)
+208 -1
View File
@@ -1,11 +1,13 @@
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。""" """config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
import contextlib
import dataclasses import dataclasses
import pytest import pytest
from loguru import logger
from polygateway.client import GatewayClient from polygateway.client import GatewayClient
from polygateway.config import GatewaySettings, OcrSettings from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
_BASE_ENV = { _BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1", "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
@@ -22,6 +24,17 @@ _BASE_ENV = {
} }
@contextlib.contextmanager
def _captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
yield messages
finally:
logger.remove(sink_id)
def _env(**overrides): def _env(**overrides):
env = dict(_BASE_ENV) env = dict(_BASE_ENV)
env.update({k: v for k, v in overrides.items() if v is not None}) env.update({k: v for k, v in overrides.items() if v is not None})
@@ -158,6 +171,11 @@ class TestAssemblyGuards:
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0")) s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
assert s2.structured_max_retries == 0 assert s2.structured_max_retries == 0
def test_negative_structured_retries_rejected_with_env_key(self):
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"):
GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1"))
def test_cache_requires_namespace_and_ttl(self): def test_cache_requires_namespace_and_ttl(self):
env = _env(PGW_CACHE_BACKEND="memory") env = _env(PGW_CACHE_BACKEND="memory")
with pytest.raises(ValueError, match="NAMESPACE"): with pytest.raises(ValueError, match="NAMESPACE"):
@@ -258,6 +276,11 @@ class TestAssemblyGuards:
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"): with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql")) GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
def test_cache_backend_whitelist(self):
"""对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。"""
with pytest.raises(ValueError, match="CACHE_BACKEND"):
GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss"))
def test_pricing_path_optional(self): def test_pricing_path_optional(self):
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json")) s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
@@ -413,3 +436,187 @@ class TestCrossFieldInvariants:
base = self._base() base = self._base()
with pytest.raises(ValueError, match="lease_ttl_s"): with pytest.raises(ValueError, match="lease_ttl_s"):
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0)) OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
# —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——
@pytest.mark.parametrize(
("field", "bad_value"),
[
("limiter_backend", "rediss"),
("breaker_backend", "sqlite"),
("cache_backend", "postgres"),
("telemetry_backend", "redis"),
("selector", "random"),
("quota_full", "block"),
],
)
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
"""域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
base = self._base()
with pytest.raises(ValueError, match=field):
dataclasses.replace(base, **{field: bad_value})
# —— 条件必填: 取 redis 的后端必须有 redis_url ——
@pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
def test_redis_backend_requires_redis_url(self, field):
"""client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
base = self._base() # redis_url=None
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, **{field: "redis"})
def test_redis_cache_requires_redis_url(self):
base = self._base()
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)
# —— 条件必填: 启用缓存必须有命名空间与正 TTL ——
def test_cache_requires_namespace(self):
"""缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
base = self._base()
with pytest.raises(ValueError, match="cache_namespace"):
dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)
def test_cache_ttl_must_be_positive(self):
"""from_env 明令禁止的"永不过期"不能从另一条路进来。"""
base = self._base()
with pytest.raises(ValueError, match="cache_ttl_s"):
dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)
# —— 条件必填: 遥测后端各自的落点 ——
def test_sqlite_telemetry_requires_path(self):
base = self._base()
with pytest.raises(ValueError, match="telemetry_sqlite_path"):
dataclasses.replace(base, telemetry_backend="sqlite")
def test_postgres_telemetry_requires_dsn(self):
base = self._base()
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
dataclasses.replace(base, telemetry_backend="postgres")
# —— 标量域 ——
def test_negative_structured_retries_rejected(self):
base = self._base()
with pytest.raises(ValueError, match="structured_max_retries"):
dataclasses.replace(base, structured_max_retries=-1)
def test_blank_scope_rejected(self):
"""空 scope 会污染遥测与缓存命名空间。"""
base = self._base()
with pytest.raises(ValueError, match="scope"):
dataclasses.replace(base, scope=" ")
# —— 合法组合仍可构造(收紧的是错的那些)——
def test_full_redis_stack_constructible(self):
base = self._base()
settings = dataclasses.replace(
base,
limiter_backend="redis",
breaker_backend="redis",
cache_backend="redis",
cache_namespace="ns",
cache_ttl_s=60,
redis_url="redis://127.0.0.1:6379/3",
)
assert settings.cache_ttl_s == 60 and settings.redis_url is not None
# —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——
def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
"""asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
base = self._base()
with _captured_warnings() as warnings:
settings = dataclasses.replace(
base,
telemetry_backend="postgres",
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
)
assert settings.telemetry_pg_dsn == "postgresql://u:s3cret@h/db"
assert any("asyncpg" in m for m in warnings)
def test_dsn_warning_does_not_leak_credentials(self):
"""DSN 带密码,日志只能出现 scheme 段(P5: 敏感信息只走 .env)。"""
base = self._base()
with _captured_warnings() as warnings:
dataclasses.replace(
base,
telemetry_backend="postgres",
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
)
assert warnings and not any("s3cret" in m or "@h/db" in m for m in warnings)
def test_env_path_strips_dsn_without_warning(self):
"""env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
with _captured_warnings() as warnings:
settings = GatewaySettings.from_env(
"LLM",
env=_env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
),
)
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
assert not warnings
# —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)——
@pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "])
def test_scope_normalized_on_direct_construction(self, raw):
"""scope 直接进 Redis key(pgw:limit:{scope}:…)。
大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间
两边各记各的配额与熔断状态,分布式治理静默失效且不报错
"""
base = self._base()
assert dataclasses.replace(base, scope=raw).scope == "llm"
def test_blank_redis_url_normalized_to_none(self):
"""空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。"""
base = self._base()
assert dataclasses.replace(base, redis_url="").redis_url is None
def test_blank_redis_url_still_blocks_redis_backend(self):
"""归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。"""
base = self._base()
with pytest.raises(ValueError, match="redis_url"):
dataclasses.replace(base, limiter_backend="redis", redis_url="")
def test_blank_pricing_path_normalized_to_none(self):
base = self._base()
assert dataclasses.replace(base, pricing_path="").pricing_path is None
# —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)——
@pytest.mark.parametrize("bad", [0, -3])
def test_embedding_settings_rejects_non_positive_batch_size(self, bad):
base = self._base()
with pytest.raises(ValueError, match="batch_size"):
EmbeddingSettings(gateway=base, batch_size=bad)
def test_embedding_settings_rejects_non_positive_expected_dim(self):
base = self._base()
with pytest.raises(ValueError, match="expected_dim"):
EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0)
def test_embedding_settings_accepts_valid_values(self):
base = self._base()
settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024)
assert settings.batch_size == 8 and settings.expected_dim == 1024
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
def test_factory_accepts_valid_redis_stack(self):
"""补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
base = self._base()
settings = dataclasses.replace(
base,
limiter_backend="redis",
breaker_backend="redis",
redis_url="redis://127.0.0.1:6379/3",
)
client = GatewayClient.from_settings(settings)
assert client is not None
+32 -2
View File
@@ -102,12 +102,14 @@ class TestEmbedTransport:
assert result.dim == 2 assert result.dim == 2
assert result.prompt_tokens == 5 and result.usage_source == "measured" assert result.prompt_tokens == 5 and result.usage_source == "measured"
async def test_missing_usage_falls_back_estimated(self): async def test_missing_usage_is_unavailable(self):
"""usage 缺失不再退到 `est_tokens`(夹具填 7),与 chat 同口径记 0 + unavailable。"""
def handler(request): def handler(request):
return httpx.Response(200, json=_ok_body([[1.0]])) return httpx.Response(200, json=_ok_body([[1.0]]))
result = await _transport_with(handler).embed(texts=["a"], source=_src(), call_id="c") result = await _transport_with(handler).embed(texts=["a"], source=_src(), call_id="c")
assert result.prompt_tokens == 7 and result.usage_source == "estimated" # est_tokens assert result.prompt_tokens == 0 and result.usage_source == "unavailable"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status", "exc_type"), ("status", "exc_type"),
@@ -161,6 +163,7 @@ from polygateway.backends.memory.breaker import InMemoryGate # noqa: E402
from polygateway.backends.memory.limiter import InMemoryLimiter # noqa: E402 from polygateway.backends.memory.limiter import InMemoryLimiter # noqa: E402
from polygateway.config import EmbeddingSettings # noqa: E402 from polygateway.config import EmbeddingSettings # noqa: E402
from polygateway.embedding import EmbeddingClient # noqa: E402 from polygateway.embedding import EmbeddingClient # noqa: E402
from polygateway.pricing import ModelPrice, PricingTable # noqa: E402
from polygateway.sources import RoundRobinSelector # noqa: E402 from polygateway.sources import RoundRobinSelector # noqa: E402
from polygateway.types import ( # noqa: E402 from polygateway.types import ( # noqa: E402
BackpressurePolicy, BackpressurePolicy,
@@ -260,6 +263,28 @@ class TestEmbedBatching:
assert resp.usage_source == "estimated" # 任一批 estimated 则整体 estimated assert resp.usage_source == "estimated" # 任一批 estimated 则整体 estimated
assert resp.prompt_tokens == 2 + 9 assert resp.prompt_tokens == 2 + 9
async def test_unavailable_batch_dominates_and_voids_cost(self):
"""三态合并优先级(设计 §3.2 #10/#11): 任一批不可得 → 整体不可得且 cost NULL。
改前二值合并只看 `estimated`,measured+unavailable 会误标 measured;
`_total_cost` 逐批求和还会给出一个偏低却看似有效的金额
"""
estimated = EmbeddingTransportResult(
vectors=[[1.0], [1.0]], dim=1, prompt_tokens=9, usage_source="estimated", raw={}
)
unavailable = EmbeddingTransportResult(
vectors=[[1.0], [1.0]], dim=1, prompt_tokens=0, usage_source="unavailable", raw={}
)
client, _ = _embed_client(
[_src()],
["ok", estimated, unavailable],
batch_size=2,
pricing=PricingTable({"embed-1": ModelPrice(input_per_1m=1.0, output_per_1m=0.0)}),
)
resp = await client.embed(["a", "b", "c", "d", "e", "f"])
assert resp.usage_source == "unavailable" # unavailable 压过 estimated 与 measured
assert resp.cost is None
class TestEmbedPostProcess: class TestEmbedPostProcess:
async def test_normalize_l2(self): async def test_normalize_l2(self):
@@ -358,6 +383,11 @@ class TestEmbeddingSettings:
s = EmbeddingSettings.from_env("EMBED", env=env) s = EmbeddingSettings.from_env("EMBED", env=env)
assert s.normalize is True and s.expected_dim == 768 assert s.normalize is True and s.expected_dim == 768
def test_expected_dim_must_be_positive(self):
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
with pytest.raises(ValueError, match="EXPECTED_DIM"):
EmbeddingSettings.from_env("EMBED", env={**self._ENV, "EMBED__EXPECTED_DIM": "0"})
def test_from_settings_assembles_client(self): def test_from_settings_assembles_client(self):
s = EmbeddingSettings.from_env("EMBED", env=self._ENV) s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
client = EmbeddingClient.from_settings(s) client = EmbeddingClient.from_settings(s)
+15
View File
@@ -394,6 +394,21 @@ class TestTelemetry:
assert recorder.rows[1]["error"] is None assert recorder.rows[1]["error"] is None
assert recorder.rows[1]["prompt_tokens"] == 0 assert recorder.rows[1]["prompt_tokens"] == 0
async def test_success_row_stays_measured_and_settles_zero(self):
"""OCR 的 0 token 是**事实**而非未知(est_tokens 解耦设计 §3.3 剔出决定)。
三态化不得把 OCR 成功行改成 `unavailable`那会灌水缺口度量
`COUNT(*) WHERE usage_source='unavailable'`;settle 0 的差异①同样不动
"""
recorder = _MemoryRecorder()
client, limiter, _ = _client([_src(tpm=1000, est_tokens=400)], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg")
row = recorder.rows[0]
assert row["usage_source"] == "measured"
assert row["prompt_tokens"] == 0 and row["completion_tokens"] == 0
assert row["error"] is None
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
class TestAssembly: class TestAssembly:
_ENV = { _ENV = {
+83 -10
View File
@@ -13,12 +13,14 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.transports.openai_compat import ( from polygateway.transports.openai_compat import (
OpenAICompatTransport, OpenAICompatTransport,
_iter_sse_deltas, _iter_sse_deltas,
_sse_data_payload, _sse_data_payload,
) )
from polygateway.types import SourceConfig from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _source(**overrides): def _source(**overrides):
@@ -71,6 +73,53 @@ async def _complete(transport, source, *, stream=True, overlay=None):
) )
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
class _MemoryRecorder:
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
async def _recorded_cost(result, source):
"""把 transport 产物走一遍真实计费路径,返回落库的 cost。
`unavailable` cost=None 的判定在 `TelemetryEmitter` (设计 §3.2 #5),
直接调 `PricingTable.cost` `0/0` 只会得到 `0.0`那正是本组用例要防的
假金额,故断言必须穿过 emitter 而不是单测 pricing
"""
recorder = _MemoryRecorder()
response = LLMResponse(
content=result.content,
thinking=result.thinking,
model=source.model,
provider=source.provider,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
latency_ms=1,
ttft_ms=result.ttft_ms,
max_inter_token_ms=result.max_inter_token_ms,
cache_hit=False,
call_id="cid-1",
source_name=source.name,
usage_source=result.usage_source,
)
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source,
call_id="cid-1",
latency_ms=1,
response=response,
error=None,
)
return recorder.rows[0]["cost"]
class TestSsePureFunctions: class TestSsePureFunctions:
def test_data_payload_filters_noise(self): def test_data_payload_filters_noise(self):
assert _sse_data_payload("") is None assert _sse_data_payload("") is None
@@ -129,13 +178,21 @@ class TestStreamHappyPath:
assert result.content == "answer" assert result.content == "answer"
assert result.thinking == "hmm" assert result.thinking == "hmm"
async def test_usage_missing_falls_back_to_est(self): async def test_usage_missing_is_unavailable_with_null_cost(self):
"""usage 帧缺失 → 0/0 + unavailable + cost NULL(设计 §3.2 #3)。
改前拿 `est_tokens` 当实测并整估值塞 completion,同一条调用记成
`0/4000` cost 0.032(设计 §1 26 倍虚高)
"""
def handler(request): def handler(request):
return _sse_stream(_chunk(content="ok")) return _sse_stream(_chunk(content="ok"))
result = await _complete(_transport_for(handler), _source(tpm=1000, est_tokens=333)) source = _source(tpm=1000, est_tokens=4000)
assert result.usage_source == "estimated" result = await _complete(_transport_for(handler), source)
assert result.prompt_tokens == 0 and result.completion_tokens == 333 assert result.usage_source == "unavailable"
assert result.prompt_tokens == 0 and result.completion_tokens == 0
assert await _recorded_cost(result, source) is None
class TestMissingDoneSemantics: class TestMissingDoneSemantics:
@@ -146,12 +203,28 @@ class TestMissingDoneSemantics:
with pytest.raises(TransientError, match="missing_done|truncated"): with pytest.raises(TransientError, match="missing_done|truncated"):
await _complete(_transport_for(self._no_done_handler), _source()) await _complete(_transport_for(self._no_done_handler), _source())
async def test_salvage_policy_keeps_content_as_estimated(self): async def test_salvage_with_usage_frame_degrades_to_estimated(self):
result = await _complete( """打捞且收到 usage 帧: 数字真实、可信度降级 → estimated 且照常计费。"""
_transport_for(self._no_done_handler), _source(missing_done="salvage") source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
) result = await _complete(_transport_for(self._no_done_handler), source)
assert result.content == "partial" assert result.content == "partial"
assert result.usage_source == "estimated" # 打捞路径强制 estimated assert result.usage_source == "estimated"
assert result.prompt_tokens == 11 and result.completion_tokens == 7
assert await _recorded_cost(result, source) == pytest.approx(
11 / 1_000_000 * 1.0 + 7 / 1_000_000 * 8.0
)
async def test_salvage_without_usage_frame_stays_unavailable(self):
"""打捞且 usage 帧缺失: 0/0 不得被洗成 estimated,否则算出假的 0.0(设计 §3.2 #4)。"""
def handler(request):
return _sse_stream(_chunk(content="partial"), done=False)
source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
result = await _complete(_transport_for(handler), source)
assert result.content == "partial"
assert result.usage_source == "unavailable"
assert await _recorded_cost(result, source) is None
async def test_early_eof_always_transient_even_under_salvage(self): async def test_early_eof_always_transient_even_under_salvage(self):
def handler(request): def handler(request):
+53
View File
@@ -152,6 +152,47 @@ class TestSuccessPath:
# 预扣 400,实际 15 → settle 后窗口只记 15 # 预扣 400,实际 15 → settle 后窗口只记 15
assert (await limiter.source_stats("a")).tpm_used == 15 assert (await limiter.source_stats("a")).tpm_used == 15
@pytest.mark.parametrize("usage_source", ["measured", "estimated"])
async def test_settle_uses_measured_sum_when_usage_available(self, usage_source):
"""用量可得(含打捞降级的 estimated)时结算恒取实测之和,不落派生兜底分支。"""
src = _src("a", tpm=1000, est_tokens=400)
result = TransportResult(
content="ok",
thinking="",
prompt_tokens=40,
completion_tokens=60,
usage_source=usage_source,
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
)
mw, limiter, *_ = _harness([src], [result])
await mw(_REQ)
# 预扣 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: class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self): async def test_transient_switches_source_then_succeeds(self):
@@ -202,6 +243,18 @@ class TestRetryAndFailover:
assert sleep.delays == [] # 源死亡不退避 assert sleep.delays == [] # 源死亡不退避
assert not (await gate.try_enter("a", "w")).allowed # a 已 force_open 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: class TestNonRetryableOutcomes:
async def test_request_rejected_propagates_without_retry(self): async def test_request_rejected_propagates_without_retry(self):
+57 -1
View File
@@ -9,6 +9,7 @@ import pytest
from polygateway.errors import CircuitOpenError, RequestRejectedError from polygateway.errors import CircuitOpenError, RequestRejectedError
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.telemetry.sqlite import SQLiteRecorder from polygateway.telemetry.sqlite import SQLiteRecorder
from polygateway.types import ChatRequest, LLMResponse, SourceConfig from polygateway.types import ChatRequest, LLMResponse, SourceConfig
@@ -68,6 +69,10 @@ def _source():
) )
# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032
_PRICING = PricingTable({"m": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
async def _record_minimal(recorder, call_id="c1", **overrides): async def _record_minimal(recorder, call_id="c1", **overrides):
fields = { fields = {
"call_id": call_id, "call_id": call_id,
@@ -168,7 +173,58 @@ class TestEmitter:
) )
row = rec.rows[0] row = rec.rows[0]
assert row["error"].startswith("TransientError") assert row["error"].startswith("TransientError")
assert row["response"] == "" and row["usage_source"] == "estimated" # 失败尝试没有任何用量信息可言 → unavailable(设计 §3.2 #6)
assert row["response"] == "" and row["usage_source"] == "unavailable"
assert row["cost"] is None
async def test_terminal_failure_row_is_unavailable(self):
rec = _MemoryRecorder()
await TelemetryEmitter(rec, pricing=_PRICING).emit_terminal_failure(
request=_REQ, call_id="cid-t", latency_ms=5, error="cancelled"
)
row = rec.rows[0]
assert row["usage_source"] == "unavailable" and row["cost"] is None
@pytest.mark.parametrize(("prompt", "completion"), [(0, 0), (0, 4000)])
async def test_unavailable_success_row_has_null_cost(self, prompt, completion):
"""产生了真实调用但用量不可得 → cost 记 NULL(设计 §3.1 不变式)。
参数第二组是改前兜底写出的 `0/4000` 形态: 那时换算出 0.032 的假金额
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt(
request=_REQ,
source=_source(),
call_id="cid-u",
latency_ms=42,
response=_resp(
usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion
),
error=None,
)
assert rec.rows[0]["cost"] is None
async def test_measured_row_still_priced(self):
"""对照组: 同一价格表下 measured 行照常换算,证明 None 不是价格表没接上。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt(
request=_REQ,
source=_source(),
call_id="cid-m",
latency_ms=42,
response=_resp(prompt_tokens=0, completion_tokens=4000),
error=None,
)
assert rec.rows[0]["cost"] == pytest.approx(0.032)
async def test_cache_hit_keeps_zero_cost_even_when_unavailable(self):
"""缓存命中未产生新调用,0.0 是事实而非未知 → 短路必须排在 cache_hit 之后。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, pricing=_PRICING).emit_cache_hit(
request=_REQ,
response=_resp(cache_hit=True, usage_source="unavailable", completion_tokens=4000),
)
assert rec.rows[0]["cache_hit"] is True and rec.rows[0]["cost"] == 0.0
async def test_multimodal_messages_digested_before_storage(self): async def test_multimodal_messages_digested_before_storage(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
+71 -3
View File
@@ -1,10 +1,12 @@
"""types.py 冻结签名的行为测试(M1 设计 §2)。""" """types.py 冻结签名的行为测试(M1 设计 §2)。"""
import dataclasses import dataclasses
import inspect
import pytest import pytest
from polygateway.types import ( from polygateway.types import (
USAGE_SOURCES,
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
ChatRequest, ChatRequest,
@@ -91,9 +93,11 @@ class TestSourceConfig:
with pytest.raises(ValueError): with pytest.raises(ValueError):
_make_source(timeout_s=0) _make_source(timeout_s=0)
def test_tpm_requires_est_tokens(self): def test_tpm_does_not_require_est_tokens(self):
with pytest.raises(ValueError): """`tpm > 0 ⇒ est_tokens > 0` 已解绑: 供应商配额可独立于库实现细节填写。"""
_make_source(tpm=10000, 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 assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800
def test_negative_gate_rejected(self): def test_negative_gate_rejected(self):
@@ -117,6 +121,70 @@ class TestSourceConfig:
_make_source(missing_done="ignore") _make_source(missing_done="ignore")
class TestEffectiveEstTokens:
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
def test_derives_from_tpm_scale_free(self):
"""派生量随配额同比缩放: 两种配额规模的在途上限同为 60 个调用。"""
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 _make_source(tpm=30).effective_est_tokens() == 1
def test_zero_when_tpm_gate_disabled(self):
"""tpm=0 即 TPM 闸未启用,无需预扣。"""
assert _make_source().effective_est_tokens() == 0
def test_explicit_value_wins(self):
"""显式配置是调优覆盖,优先于派生。"""
assert _make_source(tpm=6000, est_tokens=4000).effective_est_tokens() == 4000
def test_is_pure_sync_function(self):
"""纯方法: 非协程、可重复调用、不改动自身字段(设计 §5 并发前提)。"""
assert not inspect.iscoroutinefunction(SourceConfig.effective_est_tokens)
src = _make_source(tpm=6000)
assert src.effective_est_tokens() == src.effective_est_tokens() == 100
assert src.est_tokens == 0 # 派生不回写字段
class TestUsageSourceDomain:
"""`usage_source` 三态值域常量(设计 §3.1)。"""
def test_domain_is_exactly_three_values(self):
assert set(USAGE_SOURCES) == {"measured", "estimated", "unavailable"}
assert isinstance(USAGE_SOURCES, frozenset) # 不可变: 调用方无法就地扩张值域
@pytest.mark.parametrize(
"build",
[
lambda v: LLMResponse(
"c", "t", "m", "p", 1, 2, 3, None, None, False, "cid", usage_source=v
),
lambda v: Usage(prompt_tokens=1, completion_tokens=2, usage_source=v),
lambda v: TransportResult(
content="c",
thinking="",
prompt_tokens=1,
completion_tokens=2,
usage_source=v,
ttft_ms=None,
max_inter_token_ms=None,
raw={},
),
],
)
def test_no_runtime_validation_on_public_dataclasses(self, build):
"""越界值构造**不得**抛异常(锁定设计 §3.1 的落点裁决)。
这些是运行时构造点( retry.py:418), `ValueError` 不属 errors.py
四分类`RetryMW` 不捕它,会直接逃出 `chat()`故值域只约束生产侧,
不落在公共 frozen dataclass `__post_init__`
"""
assert build("garbage").usage_source == "garbage"
class TestResilienceConfigs: class TestResilienceConfigs:
def test_retry_policy_validation(self): def test_retry_policy_validation(self):
assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0) assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0)
+295
View File
@@ -0,0 +1,295 @@
"""`usage_source` 值域封闭: 库内所有生产点的产出恒落在 `USAGE_SOURCES` 内。
设计 §3.1 裁定值域**只约束生产侧**公共 frozen dataclass 不加运行时校验
( `ValueError` 不属四分类,会逃出 `chat()`;该裁决的锁定断言在
`test_types.py::TestUsageSourceDomain`)因此封闭性只能由"逐个驱动生产点、
断言其产出在三态内"来保证,本文件即该断言的载体。
独立成文件而非并入 `test_types.py`: 断言横跨 transports / embedding /
telemetry 三层,放进最内层内核的类型测试会让它反向依赖具体实现
覆盖的生产点(设计 §3.2 逐处改动表的字面量产出方):
`_resolve_usage``_resolve_embedding_usage``_resolve_stream_usage`(打捞覆盖)
`EmbeddingClient._merge``EmbeddingClient.embed` 空输入短路
`OcrClient._emit``TelemetryEmitter.emit_attempt/emit_cache_hit/emit_terminal_failure`
"""
import itertools
import json
import httpx
import pytest
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.embedding import EmbeddingClient, _BatchOutcome
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ocr import OcrClient
from polygateway.sources import RoundRobinSelector
from polygateway.transports.openai_compat import (
OpenAICompatTransport,
_resolve_embedding_usage,
_resolve_usage,
)
from polygateway.types import (
USAGE_SOURCES,
BackpressurePolicy,
BreakerConfig,
ChatRequest,
EmbeddingTransportResult,
GlobalLimits,
LLMResponse,
OcrTextTransportResult,
RetryPolicy,
SourceConfig,
)
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
_DOMAIN = sorted(USAGE_SOURCES)
def _src():
return SourceConfig(
name="s1",
provider="p",
base_url="https://gw.example/v1",
api_key="sk",
model="m",
timeout_s=10.0,
est_tokens=4000, # 兜底口径的历史来源: 生产点不得因它落到三态之外
)
class _MemoryRecorder:
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
@pytest.mark.parametrize(
"usage",
[
{"prompt_tokens": 12, "completion_tokens": 34}, # 完整可信
{}, # 整帧缺失
{"prompt_tokens": 0, "completion_tokens": 0}, # 全 0(和不为正)
{"prompt_tokens": "12", "completion_tokens": 34}, # 类型非法
{"prompt_tokens": None, "completion_tokens": None},
{"prompt_tokens": 12}, # 半帧
],
)
def test_resolve_usage_stays_in_domain(usage):
assert _resolve_usage(usage)[2] in USAGE_SOURCES
@pytest.mark.parametrize(
"data",
[
{"usage": {"prompt_tokens": 12}},
{},
{"usage": None},
{"usage": {}},
{"usage": {"prompt_tokens": 0}},
{"usage": {"prompt_tokens": "12"}},
],
)
def test_resolve_embedding_usage_stays_in_domain(data):
assert _resolve_embedding_usage(data)[1] in USAGE_SOURCES
def _sse(*frames, done):
"""构造 SSE 响应;done=False 触发打捞路径(`_complete_stream` 的覆盖分支)。"""
text = "".join(f"data: {json.dumps(f)}\n\n" for f in frames) + (
"data: [DONE]\n\n" if done else ""
)
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
@pytest.mark.parametrize("usage", [{"prompt_tokens": 11, "completion_tokens": 7}, None])
async def test_salvage_override_stays_in_domain(usage):
"""打捞覆盖(`openai_compat._complete_stream`)是第三个字面量产出方。"""
frames = [{"choices": [{"delta": {"content": "partial"}}]}]
if usage is not None:
frames.append({"choices": [], "usage": usage})
transport = OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(
base_url=src.base_url,
transport=httpx.MockTransport(lambda request: _sse(*frames, done=False)),
)
)
source = SourceConfig(
name="s1",
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk",
model="m",
timeout_s=10.0,
est_tokens=4000,
missing_done="salvage",
)
result = await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=source,
stream=True,
overlay={},
call_id="cid",
)
assert result.usage_source in USAGE_SOURCES
class _ScriptedOcrTransport:
async def recognize_text(self, *, image, source, call_id):
return OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
async def parse_layout(self, *, image, source, call_id):
raise NotImplementedError
async def check_health(self, *, source):
raise NotImplementedError
async def test_ocr_emit_stays_in_domain():
"""`OcrClient._emit` 的字面量(ocr.py:411)同样纳入封闭性断言。
值取 `measured` 是设计 §3.3 的裁决(OCR 0 token 属事实);此处只断言
落在三态内,精确取值的防回归钉在 `test_ocr_client.py`
"""
source = SourceConfig(
name="m1",
provider="monkey",
base_url="http://gw.example",
api_key="none",
model="monkey-ocr",
timeout_s=10.0,
)
recorder = _MemoryRecorder()
client = OcrClient(
scope="ocr",
sources=[source],
selector=RoundRobinSelector(),
limiter=InMemoryLimiter(
scope="ocr",
sources={source.name: source},
global_limits=GlobalLimits(max_concurrency=0, rpm=0, tpm=0),
lease_ttl_s=100.0,
),
breaker=InMemoryGate(
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
),
transport=_ScriptedOcrTransport(),
retry=RetryPolicy(max_attempts=1, backoff_base_s=0.001, backoff_max_s=0.01),
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.001),
telemetry=recorder,
)
await client.recognize_text(b"jpg")
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
def _merge_client():
"""构造仅用于调用 `_merge` 的最小 EmbeddingClient(不发起任何调用)。"""
source = _src()
return EmbeddingClient(
scope="embed",
sources=[source],
selector=RoundRobinSelector(),
limiter=InMemoryLimiter(
scope="embed",
sources={source.name: source},
global_limits=GlobalLimits(max_concurrency=0, rpm=0, tpm=0),
lease_ttl_s=100.0,
),
breaker=InMemoryGate(
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
),
transport=object(),
retry=RetryPolicy(max_attempts=1, backoff_base_s=0.001, backoff_max_s=0.01),
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.001),
batch_size=2,
)
@pytest.mark.parametrize(("first", "second"), list(itertools.product(_DOMAIN, repeat=2)))
def test_merge_stays_in_domain(first, second):
"""任意两批 usage_source 组合(含尚无生产者的 unavailable)合并后仍在三态内。"""
source = _src()
outcomes = [
_BatchOutcome(
result=EmbeddingTransportResult(
vectors=[[1.0]], dim=1, prompt_tokens=1, usage_source=value, raw={}
),
source=source,
call_id="c",
latency_ms=1,
)
for value in (first, second)
]
assert _merge_client()._merge(outcomes).usage_source in USAGE_SOURCES
async def test_empty_input_short_circuit_stays_in_domain():
"""空输入短路自造响应(embedding.py:151),不经 transport 也须落在三态内。"""
resp = await _merge_client().embed([])
assert resp.usage_source in USAGE_SOURCES
def _resp(usage_source):
return LLMResponse(
content="ok",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=2,
latency_ms=30,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="cid",
source_name="s1",
usage_source=usage_source,
)
@pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_attempt_success_stays_in_domain(emitted):
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_attempt(
request=_REQ,
source=_src(),
call_id="cid",
latency_ms=10,
response=_resp(emitted),
error=None,
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
async def test_emit_attempt_failed_attempt_stays_in_domain():
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_attempt(
request=_REQ,
source=_src(),
call_id="cid",
latency_ms=10,
response=None,
error="boom",
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_cache_hit_stays_in_domain(emitted):
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_cache_hit(request=_REQ, response=_resp(emitted))
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
async def test_emit_terminal_failure_stays_in_domain():
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_terminal_failure(
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES