mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 05:42:19 +03:00
Merged via /merge-batch (v3.8.51 provenance sweep). Boarded and validated together with the batch's other retirement PRs in a combined worktree — full gate suite green. This PR's conflicts (against #11720's Designer retirement, both introducing a retirement-guard mechanism across executors/index.ts, executorProxy.ts, providers.ts, reservedProviderPrefixes.ts, auth.ts, chat.ts, chatHelpers.ts, model.ts) were reconciled by combining both guards at every chokepoint, with the shared reserved-prefix count recomputed (not guessed) at 400. Re-validated with this PR's own 72 node:test + 20 vitest focused tests, all passing, and pushed before merge. Thank you.
144 lines
8.8 KiB
Markdown
144 lines
8.8 KiB
Markdown
---
|
|
title: "STRICT_ZERO_COST"
|
|
version: 3.8.50
|
|
lastUpdated: 2026-08-20
|
|
---
|
|
|
|
# STRICT_ZERO_COST
|
|
|
|
> Opt-in, off by default (`settings.freeAccessPolicy !== "strict"` leaves every `auto/*`
|
|
> candidate pool byte-identical). A stricter sibling of `hidePaidModels`
|
|
> (`open-sse/services/autoCombo/paidModelFilter.ts`, #6512) for operators who need a hard
|
|
> guarantee against ANY incremental monetary spend, not just "documented as free".
|
|
|
|
## Why this exists, and why `hidePaidModels` alone isn't enough
|
|
|
|
`hidePaidModels` answers "is this model classified free in `FREE_MODEL_BUDGETS` right now?" —
|
|
a point-in-time catalog fact, checked via `isFreeModel()`/`providerHasFreeModels()`
|
|
(`src/shared/utils/freeModels.ts`). It says nothing about two real risks:
|
|
|
|
1. A `recurring-*`/`one-time-initial` free tier's allowance can be **exhausted** — the catalog
|
|
still lists the model as free, but the account behind it has no headroom left.
|
|
2. Exceeding a free tier is not always a hard stop. Some providers document explicitly that no
|
|
payment method can ever be attached ("no credit card required"); others don't say, and a
|
|
handful bill automatically past the free allowance.
|
|
|
|
`hidePaidModels` cannot distinguish these — it was never meant to. STRICT_ZERO_COST adds exactly
|
|
these two checks, evaluated per candidate, **before** category/tier ranking and **before**
|
|
dispatch — never after a request has already gone out.
|
|
|
|
## Candidate classification
|
|
|
|
For every candidate in the pool (`open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`,
|
|
right after `filterPaidOnlyCandidates`):
|
|
|
|
1. **Not in `FREE_MODEL_BUDGETS` at all** → excluded. This covers genuinely paid models and any
|
|
provider/model OmniRoute hasn't classified yet — new candidates start excluded, not included.
|
|
2. **`freeType: "keyless"`** → passes immediately, **but only for a candidate that genuinely
|
|
arrived via the no-auth path** (`connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`,
|
|
`open-sse/services/autoCombo/resilienceCandidateFilter.ts`). No credential exists for that
|
|
candidate, so no request against it can ever be billed — no runtime check is needed or
|
|
possible. The same catalogued `keyless` provider/model reached through a **real** DB
|
|
connection (`connectionId` is an actual connection id, or the candidate carries
|
|
`allowedConnectionIds`) does **not** get this shortcut — `keyless` metadata describes the
|
|
no-auth path specifically, not the provider in general, and never authorizes a real,
|
|
credentialed account. Such a candidate falls through to check 3 like any other, where it is
|
|
excluded unless the catalog entry separately carries `hardStopGuaranteed: true` (real
|
|
`keyless` entries never do — the shortcut was their only path to safety).
|
|
3. **Any other `freeType`** (`recurring-daily`, `recurring-monthly`, `recurring-credit`,
|
|
`recurring-uncapped`, `one-time-initial`, and any future type this module doesn't
|
|
special-case) → passes only if **all** of the following hold:
|
|
- `hardStopGuaranteed: true` is set on the catalog entry (`FreeModelBudget.hardStopGuaranteed`,
|
|
`open-sse/config/freeModelCatalog.ts`) — a **curated, hand-set fact** about the provider's
|
|
own published terms (e.g. an explicit "no credit card required" claim), never derived from
|
|
`freeType` or from a live API response. Unset (`undefined`) and `false` are both treated as
|
|
"not guaranteed".
|
|
- A usage adapter exists for the provider in `USAGE_FETCHER_PROVIDERS`
|
|
(`open-sse/services/usage.ts`) — the same registry that already backs the quota dashboard and
|
|
`getUsageForProvider()`. No adapter → excluded, permanently, until one is added.
|
|
- The live, cached `FreeAccessState` for **the specific connection actually being
|
|
evaluated** is `status: "SAFE"`, was checked within
|
|
`settings.autoRefreshProviderQuotaInterval` (default 180s — the existing setting, not a new
|
|
number), and reports `remainingFreeAllowance` above a small safety margin.
|
|
4. **`freeType: "discontinued"`** → always excluded.
|
|
|
|
## Connection safety (per-connection verification, never per-candidate)
|
|
|
|
A candidate in the auto-combo pool is not always tied to one connection. A "logical" candidate
|
|
(`connectionId: null`) carries an `allowedConnectionIds` allowlist — one or more actual
|
|
provider connections/accounts any of which could serve the request — and the account actually
|
|
used is decided later, at dispatch time, by `open-sse/services/combo/autoStrategy.ts`
|
|
(intersecting `allowedConnectionIds` against its own connection-selection logic, ~line 315-331).
|
|
|
|
STRICT_ZERO_COST verifies the free-access state of **each connection in that allowlist
|
|
individually** (`evaluateCandidateConnections()` in `strictZeroCostFilter.ts`) and rewrites
|
|
`allowedConnectionIds` down to exactly the subset that came back `SAFE` — never the full
|
|
original list, and never a single arbitrarily-chosen member. Concretely:
|
|
|
|
- Account A `SAFE`, account B `UNKNOWN`/exhausted/billable → only A remains selectable.
|
|
- All accounts `UNKNOWN` → the candidate is dropped entirely (empty safe set).
|
|
- A single-connection candidate (`connectionId` set directly, no allowlist) that fails is
|
|
dropped outright, never returned with an empty `allowedConnectionIds`.
|
|
|
|
Because `autoStrategy.ts` already enforces `allowedConnectionIds` as a hard allowlist before
|
|
selecting a connection to dispatch to, rewriting it to the verified-SAFE subset is sufficient to
|
|
guarantee the connection actually used at dispatch is always one this filter itself verified —
|
|
never a different, unverified account on the same candidate. See
|
|
`tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts` for the regression proof
|
|
(keyless-bypass cases A/B/C, multi-account cases 1-5).
|
|
|
|
`discovered automatically`: a provider/model shipped tomorrow with the right metadata (in the
|
|
catalog, with a usage adapter, `hardStopGuaranteed: true`) is usable the moment OmniRoute knows
|
|
about it — no code change, no whitelist entry, nothing to edit in this module. One removed from
|
|
the catalog disappears the same way. See
|
|
`tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts` for the regression proof (via
|
|
injectable fixtures, not by mutating the real catalog).
|
|
|
|
## Quota caching (`open-sse/services/autoCombo/freeAccessQuota.ts`)
|
|
|
|
Reuses `getUsageForProvider()` — no second quota system. A short, in-memory,
|
|
process-lifetime cache sits in front of it (TTL equal to the default
|
|
`autoRefreshProviderQuotaInterval`) so a Telegram-scale request rate never triggers a live
|
|
billing-API call per candidate per request. Reads are synchronous: a cache miss returns
|
|
`undefined` (→ excluded, fail-closed) and kicks off a background refresh for the _next_ read —
|
|
nothing in the candidate-pool build path ever awaits a network call.
|
|
|
|
`invalidateFreeAccessState(provider, connectionId)` is called from
|
|
`src/sse/services/auth.ts::markAccountUnavailable()` the moment a connection fails for any
|
|
reason, so the very next pool build reads a clean cache miss instead of a stale `SAFE` entry —
|
|
no waiting out the TTL after a 402/403/quota-exhausted response.
|
|
|
|
## ToS guard (independent of economic safety)
|
|
|
|
`excludeTosAvoid` (default `false`) drops any candidate whose curated `tos` verdict
|
|
(`FreeModelBudget.tos`) is `"avoid"` — reuses the same field `hidePaidModels`'s sibling docs
|
|
(`docs/reference/FREE_TIERS.md`) already populate. Deliberately separate from
|
|
`freeAccessPolicy`: a candidate can be economically `SAFE` and still excluded here for
|
|
contractual reasons, or left in when this guard is off even with `freeAccessPolicy: "strict"` on.
|
|
|
|
## What passes today
|
|
|
|
Run `npx tsx scripts/ad-hoc/dry-run-strict-zero-cost.ts` against a live instance's
|
|
`GET /v1/auto-combo/{channel}/candidates` output for a real before/after — the script reads each
|
|
candidate's real `connectionId`, so it also proves the connection-safety fix live, not just in
|
|
unit tests. Keyless candidates must arrive with the synthetic no-auth `connectionId`, never a
|
|
real connection. The current built-in keyless auto path is OpenCode Free; exact candidate counts
|
|
still depend on live model discovery and should be measured on the target deployment instead of
|
|
copied from an older run. A `recurring-*` candidate passes only when it has both a registered
|
|
usage adapter and `hardStopGuaranteed: true`; incomplete metadata remains fail-closed.
|
|
|
|
With `excludeTosAvoid: true`, every candidate curated as `tos: "avoid"` is removed. OpenCode Free
|
|
currently carries that verdict, so enabling the guard can empty a deployment's remaining keyless
|
|
pool. This is an expected trade-off of turning the ToS guard on, not a bug: the guard is `false`
|
|
by default for exactly this reason (see "ToS guard" above).
|
|
|
|
## Enabling
|
|
|
|
```json
|
|
PUT /api/settings
|
|
{ "freeAccessPolicy": "strict", "excludeTosAvoid": false }
|
|
```
|
|
|
|
Both new settings default to their pre-feature values (`"off"` / `false`) — enabling neither
|
|
changes any existing `auto/*` routing behavior.
|