Skip to content

fix(gateway): require a price before a request reaches upstream - #299

Merged
argszero merged 1 commit into
mainfrom
fix/route-requires-a-price
Sep 25, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/route-requires-a-price

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

A keys row is routed by model alone (dao::find_keys_by_model has no provider in its SQL) while it is priced by (key.provider, model) against the models catalog. The catalog has three writers and none of them maintains keys:

# writer what it does
W1 db::seed_models startup sync with config.toml — DELETE FROM models WHERE provider=?1 AND model=?2 for rows config no longer lists
W2 admin_models::remove DELETE FROM models WHERE id = ?1
W3 admin_models::patch UPDATE models SET provider = ?1, model = ?2, … — both fields are editable in the admin form

So a status='on' key can reach the state "routable but unpriceable" after it was created, and there it is billed as free: the call returns 200, settle_usage falls into None => (0.0, 0.0), the consumer is not charged, the sharer earns nothing and usage_records.cost is 0. Because pts = 0 never lowers the balance, one funded consumer can call that model unlimited times for free.

sharing::create already refuses to create such a key (C2065, src/routes/sharing.rs), so this is one invariant with two doors — only the creation door was closed. A catalog edit (rename/delete a model row, or a config shrink at startup) walks back in through the other one.

Related Issue

None — the repository has no open issues (all three are CLOSED). This defect came from this task's own reconnaissance, which found it by reading the source, not by filing an issue. The invariant itself is the maintainers' own wording, from the creation guard's test documentation in src/routes/sharing.rs:

查不到 settle_usage 取 None => (0.0, 0.0) ⇒ 一次真实调用会静默变成免费:消费者不扣点、分享者无收益、usage_records.cost=0,而调用本身 200 正常返回。故入口拒绝。

Changes

  • Close the second door the same way the first one is closed: forward and forward_stream price each candidate key before picking one, and refuse with 503 (“provider 与 model 不在模型目录中,无法计价”) before anything is sent upstream. Candidates that cannot be priced are dropped from the pick set rather than relying on stickiness/randomness never choosing them.
  • forward_stream needed the same gate — otherwise the two protocols diverge.
  • The settlement fallback arm becomes a loud log::error! instead of a silent zero: a real call must not be billed as free just because its catalog row moved between the preflight and the settle.
  • No new i18n key: the refusal sentence already exists in src/routes/sharing.rs and is mapped by ERR_MAP (err.modelNotInCatalog), so the route side says exactly what the creation side already says.
  • src/db.rs's catalog-deletion comment becomes an invariant instead of a roster of historical referrers (it listed usage_records/transactions and missed the one live referrer, keys.model). Zero behavior change.

Tests

  • cargo test — 392 passed, 0 failed (baseline 387; +4 gate tests, +1 runtime test)
  • cargo fmt --check — rc=0
  • cargo clippy --all-targets -- -D warnings — rc=0
  • New/updated tests:
    • gateway::tests::unpriced_model_503_before_upstream — a key whose (provider, model) has no catalog row is refused with 503, and the key is not pushed into cooldown (the call never reached upstream).
    • gateway::tests::dead_upstream_failover_503 — fixtures updated to seed a price row so the test still exercises the failover path.
    • state_gate::{the_router_requires_a_price_before_it_reaches_upstream, the_r156_roster_is_real, the_r156_rules_have_teeth, the_r156_scanners_have_teeth}

The gate

state_gate::the_router_requires_a_price_before_it_reaches_upstream derives the forwarder roster from “who calls router.pick(” and requires:

  1. R1 every forwarder prices its candidates before picking a key;
  2. R2 the settlement fallback arm logs;
  3. R3 the price call's result actually drives the branch (so a discarded result — or a blanket refusal — fails).

Each rule has an independent mutant; a roster self-check and scanner self-checks accompany them.

A/B (both instruments)

  • Gate, compile-time teeth: reverted src/gateway.rs to the pre-fix commit (git archive 8f8fcdc), separate CARGO_TARGET_DIR per leg — the axis test FAILED with exactly the declared reading r1=false r2=false r3=true | forwarders={"forward","forward_stream"} price_present={} gated={} settle_found=true settle_logs=false; restored ⇒ PASS.
  • Mechanism probe (r156_model_price_invariant_probe.py, SQL extracted from source at run time): against the pre-fix tree 11/11 as declared — L2 (admin rename) / L3 (admin delete) / L4 (startup seed delete) all read routable, unpriceable, free; against the fixed tree the same three writers read closed (503 before upstream), with the catalog row present the call is still routed (no blanket refusal), and the partial fixes (L7b: cleanup wired into W2 only ⇒ W1/W3 leak; L7c: W2+W3 but forgetting the startup seed ⇒ W1 leaks) remain rejected.

Scope, stated honestly: the gate is static/lexical — it proves the shape (“every forwarder prices before picking”, “the fallback arm logs”, “the result drives the branch”); it does not prove the runtime “a request with a catalog row still returns 200”. That half belongs to the mechanism probe and the new runtime test.

Checklist

  • 分支命名符合约定 (fix/)
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化

A `keys` row is routed by `model` alone (`dao::find_keys_by_model` has no
`provider` in its SQL), while billing prices it by `(key.provider, model)`
against the `models` catalog. The catalog has three writers and none of them
maintains `keys`: `db::seed_models` (startup sync with config.toml),
`admin_models::remove`, and `admin_models::patch` (both `provider` and `model`
are editable in the admin form). So a `status='on'` key can reach the state
"routable but unpriceable" *after* it was created — and there it is billed as
free.

`sharing::create` already refuses to create such a key (C2065: "查不到即 0
计费 —— 一次真实调用会静默变成免费"), so this is one invariant with two
doors; only the creation door was closed. A catalog edit — rename or delete a
model row, or a config shrink at startup — reopens it: the call returns 200,
`settle_usage` falls into `None => (0.0, 0.0)`, the consumer is not charged,
the sharer earns nothing and `usage_records.cost` is 0. Because `pts = 0` never
lowers the balance, one funded consumer can call that model unlimited times for
free.

Close the second door the same way the first one is closed: `forward` and
`forward_stream` price each candidate key before picking one, and refuse with
503 ("provider 与 model 不在模型目录中,无法计价") before anything is sent
upstream. Candidates that cannot be priced are dropped from the pick set rather
than relying on stickiness/randomness never choosing them. The settlement
fallback arm becomes a loud `log::error!` instead of a silent zero — a real
call must not be billed as free just because its catalog row moved between the
preflight and the settle ("missing data" is not "data of zero").

Flow control: `forward_stream` needs the same gate, otherwise the two protocols
diverge. No new i18n key: the refusal sentence already exists in
`sharing.rs` and is mapped by `ERR_MAP` (`err.modelNotInCatalog`), so the route
side says exactly what the creation side already says.

Gate `state_gate::the_router_requires_a_price_before_it_reaches_upstream`
derives the forwarder roster from "who calls `router.pick(`" and requires
(a) every forwarder to price its candidates before picking a key,
(b) the settlement fallback arm to log, and (c) the price call's result to
actually drive the branch (so a discarded result or a blanket refusal fails).
Each rule has an independent mutant. `db.rs`'s catalog-deletion comment becomes
an invariant instead of a roster of historical referrers.

Co-authored-by: argszero <argszero@argszerodeMac-mini.local>
@argszero
argszero merged commit acb8815 into main Sep 25, 2026
1 check passed
@argszero
argszero deleted the fix/route-requires-a-price branch September 25, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant