Repository navigation
fix(gateway): require a price before a request reaches upstream - #299
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A
keysrow is routed bymodelalone (dao::find_keys_by_modelhas noproviderin its SQL) while it is priced by(key.provider, model)against themodelscatalog. The catalog has three writers and none of them maintainskeys:db::seed_modelsconfig.toml—DELETE FROM models WHERE provider=?1 AND model=?2for rows config no longer listsadmin_models::removeDELETE FROM models WHERE id = ?1admin_models::patchUPDATE models SET provider = ?1, model = ?2, …— both fields are editable in the admin formSo 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_usagefalls intoNone => (0.0, 0.0), the consumer is not charged, the sharer earns nothing andusage_records.costis 0. Becausepts = 0never lowers the balance, one funded consumer can call that model unlimited times for free.sharing::createalready 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:Changes
forwardandforward_streamprice each candidate key before picking one, and refuse with503(“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_streamneeded the same gate — otherwise the two protocols diverge.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.src/routes/sharing.rsand is mapped byERR_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 listedusage_records/transactionsand 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=0cargo clippy --all-targets -- -D warnings— rc=0gateway::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_upstreamderives the forwarder roster from “who callsrouter.pick(” and requires:Each rule has an independent mutant; a roster self-check and scanner self-checks accompany them.
A/B (both instruments)
src/gateway.rsto the pre-fix commit (git archive 8f8fcdc), separateCARGO_TARGET_DIRper leg — the axis test FAILED with exactly the declared readingr1=false r2=false r3=true | forwarders={"forward","forward_stream"} price_present={} gated={} settle_found=true settle_logs=false; restored ⇒ PASS.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/)