Skip to content

Commit f737e5b

Browse files
author
Hermes Freebrain
committed
fix: resolve free routing from live provider economics
1 parent c3945a7 commit f737e5b

22 files changed

Lines changed: 694 additions & 130 deletions

docs/routing/STRICT_ZERO_COST.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: "STRICT_ZERO_COST"
3-
version: 3.8.50
4-
lastUpdated: 2026-08-20
3+
version: 3.8.51
4+
lastUpdated: 2026-09-04
55
---
66

77
# STRICT_ZERO_COST
@@ -13,9 +13,9 @@ lastUpdated: 2026-08-20
1313
1414
## Why this exists, and why `hidePaidModels` alone isn't enough
1515

16-
`hidePaidModels` answers "is this model classified free in `FREE_MODEL_BUDGETS` right now?" —
17-
a point-in-time catalog fact, checked via `isFreeModel()`/`providerHasFreeModels()`
18-
(`src/shared/utils/freeModels.ts`). It says nothing about two real risks:
16+
`hidePaidModels` answers "is this candidate classified free?" using the shared free-model
17+
classifier plus connection-scoped discovery evidence and explicit local tier policy. It is still
18+
a classification filter, not a current-spend proof, and says nothing about two real risks:
1919

2020
1. A `recurring-*`/`one-time-initial` free tier's allowance can be **exhausted** — the catalog
2121
still lists the model as free, but the account behind it has no headroom left.
@@ -32,20 +32,29 @@ dispatch — never after a request has already gone out.
3232
For every candidate in the pool (`open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`,
3333
right after `filterPaidOnlyCandidates`):
3434

35-
1. **Not in `FREE_MODEL_BUDGETS` at all** → excluded. This covers genuinely paid models and any
36-
provider/model OmniRoute hasn't classified yet — new candidates start excluded, not included.
37-
2. **`freeType: "keyless"`** → passes immediately, **but only for a candidate that genuinely
35+
1. **Explicit local tier policy** is highest precedence. `free` admits the real connection set;
36+
`cheap`/`premium` excludes even when lower-precedence catalog or discovery evidence says free.
37+
2. **Fresh connection-scoped discovery evidence** (`isFree`, a `:free` id, or exact zero pricing
38+
observed in that provider response) admits only the connections that reported it free. The
39+
successful discovery must be newer than `autoRefreshProviderQuotaInterval`; after restart or
40+
expiry it becomes `state-unknown`, while the ordinary non-strict router may still use the LKG.
41+
3. **Nous Portal `:free` variants** are provider-owned no-cost variants and may pass without a
42+
static catalog row. Other Nous models do not get this shortcut; Portal recommendations/pricing
43+
determine their tier dynamically.
44+
4. **Not in `FREE_MODEL_BUDGETS` after those higher-precedence checks** → excluded. This covers
45+
genuinely paid models and candidates OmniRoute still cannot prove zero-cost.
46+
5. **`freeType: "keyless"`** → passes immediately, **but only for a candidate that genuinely
3847
arrived via the no-auth path** (`connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`,
3948
`open-sse/services/autoCombo/resilienceCandidateFilter.ts`). No credential exists for that
4049
candidate, so no request against it can ever be billed — no runtime check is needed or
4150
possible. The same catalogued `keyless` provider/model reached through a **real** DB
4251
connection (`connectionId` is an actual connection id, or the candidate carries
4352
`allowedConnectionIds`) does **not** get this shortcut — `keyless` metadata describes the
4453
no-auth path specifically, not the provider in general, and never authorizes a real,
45-
credentialed account. Such a candidate falls through to check 3 like any other, where it is
54+
credentialed account. Such a candidate falls through to check 6 like any other, where it is
4655
excluded unless the catalog entry separately carries `hardStopGuaranteed: true` (real
4756
`keyless` entries never do — the shortcut was their only path to safety).
48-
3. **Any other `freeType`** (`recurring-daily`, `recurring-monthly`, `recurring-credit`,
57+
6. **Any other `freeType`** (`recurring-daily`, `recurring-monthly`, `recurring-credit`,
4958
`recurring-uncapped`, `one-time-initial`, and any future type this module doesn't
5059
special-case) → passes only if **all** of the following hold:
5160
- `hardStopGuaranteed: true` is set on the catalog entry (`FreeModelBudget.hardStopGuaranteed`,
@@ -60,7 +69,7 @@ right after `filterPaidOnlyCandidates`):
6069
evaluated** is `status: "SAFE"`, was checked within
6170
`settings.autoRefreshProviderQuotaInterval` (default 180s — the existing setting, not a new
6271
number), and reports `remainingFreeAllowance` above a small safety margin.
63-
4. **`freeType: "discontinued"`** → always excluded.
72+
7. **`freeType: "discontinued"`** → always excluded.
6473

6574
## Connection safety (per-connection verification, never per-candidate)
6675

@@ -125,17 +134,18 @@ leaves the field `null` everywhere and costs nothing.
125134

126135
| `freeAccessExclusion` | What it means | What to do about it |
127136
| :--------------------- | :------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------- |
128-
| `not-in-catalog` | The provider/model pair is absent from `FREE_MODEL_BUDGETS`. | Add a curated entry, or accept that new pairs start excluded — that is the design. |
137+
| `operator-non-free` | Local tier policy explicitly marks this provider/model `cheap` or `premium`. | Change/remove the local override only if that policy is no longer intended. |
138+
| `not-in-catalog` | No higher-precedence free proof exists and the pair is absent from `FREE_MODEL_BUDGETS`. | Add a curated entry or obtain live/explicit zero-cost evidence; unknown pairs fail closed. |
129139
| `regime-not-free` | Catalogued, but its `freeType` is not one that grants free access (a discontinued tier, for instance). | Nothing to fix. The model costs money. |
130140
| `no-hard-stop` | Free regime, but `hardStopGuaranteed` is not `true`, so exceeding the allowance might silently start billing. | Verify the provider's terms and set the flag with the source in a comment — never to grow the catalog. |
131141
| `contradictory-noauth` | A no-auth candidate whose catalog entry is not `keyless`. Fail-closed on inconsistent metadata. | Fix the catalog entry; the two facts disagree. |
132142
| `exhausted` | A fresh reading says the allowance is used up. | Wait for the reset. This one resolves itself. |
133-
| `state-unknown` | No quota reading, or one too old to trust. | Go look: the provider may have no usage adapter registered, or the quota fetch is failing. |
143+
| `state-unknown` | No fresh quota or live-discovery proof exists for this account. | Refresh discovery/quota state; missing adapters or expired process-local evidence stay fail-closed. |
134144
| `no-connection` | The candidate carries no account to check at all. | Not a quota problem: the candidate was built without a connection, so nothing was ever looked up. Check how the pool was assembled. |
135145

136-
The last two are the pair worth separating. An exhausted allowance resets on its own; a reading
137-
that never arrives means the lookup itself is broken, and until now both looked identical from
138-
outside — the candidate simply vanished.
146+
The `exhausted` / `state-unknown` pair is worth separating. An exhausted allowance resets on its
147+
own; a reading that never arrives or live-discovery proof that expires means current safety is
148+
unknown, and until now both looked identical from outside — the candidate simply vanished.
139149

140150
**One gap remains, and it is deliberate.** `excludeTosAvoid` still removes candidates before the
141151
listing is built, so a model curated `tos: "avoid"` is absent with no reason given — the same

open-sse/services/__tests__/tierResolver.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ describe("TierResolver", () => {
125125
expect(result.reason.includes("override")).toBe(true);
126126
});
127127

128+
it("lets an explicit non-free override beat default free-provider classification", () => {
129+
setTierConfig({ providerOverrides: [{ provider: "groq", tier: "premium" }] });
130+
const result = classifyTier("groq", "openai/gpt-oss-120b");
131+
expect(result.tier).toBe(PROVIDER_TIER.PREMIUM);
132+
expect(result.hasFreeTier).toBe(false);
133+
});
134+
128135
it("respects model-level glob pattern override", () => {
129136
setTierConfig({
130137
modelOverrides: [{ provider: "openai", modelPattern: "gpt-4o-mini*", tier: "cheap" }],

open-sse/services/autoCombo/freeAccessQuota.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type UsageFetcherProvider,
2121
} from "./../usage.ts";
2222
import { getCachedProviderConnections } from "@/lib/db/readCache";
23+
import { invalidateProviderModelDiscoveryFreshness } from "@/lib/providerModels/discoveryFreshness";
2324
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
2425
import type { FreeAccessState } from "./strictZeroCostFilter";
2526
import { isStateStaleForReset } from "./subscriptionLadder";
@@ -211,6 +212,7 @@ export function resolveFreeAccessState(
211212
* build reads a clean cache miss (UNKNOWN) rather than a stale SAFE. */
212213
export function invalidateFreeAccessState(provider: string, connectionId: string): void {
213214
cache.delete(cacheKey(provider, connectionId));
215+
invalidateProviderModelDiscoveryFreshness(provider, connectionId);
214216
}
215217

216218
export const __testing = { cache, extractRemainingAllowance, sweepIfDue };
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
type SyncedFreeEvidence = Record<string, Array<{ id: string; isFree?: boolean }>>;
2+
3+
/** Convert per-connection discovery evidence into a candidate field without widening scope. */
4+
export function discoveredFreeConnectionScope(
5+
allowedConnectionIds: string[],
6+
syncedByConnection: SyncedFreeEvidence,
7+
modelId: string
8+
): { freeConnectionIds?: string[] } {
9+
const freeConnectionIds = allowedConnectionIds.filter((connectionId) =>
10+
(syncedByConnection[connectionId] ?? []).some((m) => m.id === modelId && m.isFree === true)
11+
);
12+
return freeConnectionIds.length > 0 ? { freeConnectionIds } : {};
13+
}

open-sse/services/autoCombo/paidModelFilter.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,52 @@
1111
* Kept as a pure, dependency-light function so the filter is unit-testable in
1212
* isolation without seeding the DB-backed virtual factory.
1313
*/
14-
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
14+
import { isFreeModel } from "@/shared/utils/freeModels";
1515

1616
interface PaidFilterCandidate {
1717
provider: string;
1818
model: string;
19-
}
20-
21-
/** A candidate is kept only when its provider has documented free models AND the
22-
* selected model itself qualifies as free — mirrors `shouldHidePaid` in
23-
* `src/app/api/v1/models/catalog.ts`. */
24-
function isFreeCandidate(candidate: PaidFilterCandidate): boolean {
25-
return (
26-
providerHasFreeModels(candidate.provider) &&
27-
isFreeModel(candidate.provider, { id: candidate.model })
28-
);
19+
allowedConnectionIds?: string[];
20+
freeConnectionIds?: string[];
2921
}
3022

3123
/**
3224
* Return the candidate pool filtered to free-only backends when
3325
* `hidePaidModels` is on; otherwise return the pool unchanged (identity — the
34-
* default, opt-in-off path). If every candidate is paid the result is empty, and
35-
* the caller's existing graceful empty-pool path handles it (consistent with the
36-
* opt-in intent — the operator asked not to route to paid models).
26+
* default, opt-in-off path). Connection-scoped discovery is honored without
27+
* widening it: when only some credentials reported this model free, the
28+
* candidate's dispatch allowlist is narrowed to exactly those connections.
3729
*/
3830
export function filterPaidOnlyCandidates<T extends PaidFilterCandidate>(
3931
pool: T[],
40-
hidePaidModels: boolean
32+
hidePaidModels: boolean,
33+
resolveOperatorTier: (
34+
provider: string,
35+
model: string
36+
) => "free" | "cheap" | "premium" | undefined = () => undefined
4137
): T[] {
4238
if (!hidePaidModels) return pool;
43-
return pool.filter(isFreeCandidate);
39+
40+
const kept: T[] = [];
41+
for (const candidate of pool) {
42+
const override = resolveOperatorTier(candidate.provider, candidate.model);
43+
if (override !== undefined) {
44+
if (override === "free") kept.push(candidate);
45+
continue;
46+
}
47+
if (isFreeModel(candidate.provider, { id: candidate.model })) {
48+
kept.push(candidate);
49+
continue;
50+
}
51+
52+
const freeConnections = candidate.freeConnectionIds ?? [];
53+
if (freeConnections.length === 0) continue;
54+
const allowed = candidate.allowedConnectionIds ?? [];
55+
const narrowed = freeConnections.filter((id) => allowed.includes(id));
56+
if (narrowed.length === 0) continue;
57+
const same =
58+
allowed.length === narrowed.length && narrowed.every((id) => allowed.includes(id));
59+
kept.push(same ? candidate : ({ ...candidate, allowedConnectionIds: narrowed } as T));
60+
}
61+
return kept;
4462
}

open-sse/services/autoCombo/strictZeroCostFilter.ts

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616
* lives in `freeAccessQuota.ts` and is injected here as a plain function —
1717
* this file never imports the DB or makes a network call itself.
1818
*
19-
* No provider or model name appears anywhere in this file. A candidate passes
20-
* or fails purely on the metadata it carries (`freeType`, `tos`,
21-
* `hardStopGuaranteed`) plus, for quota-based types, a `FreeAccessState`
22-
* resolved elsewhere. A future provider that ships correct metadata is
23-
* handled automatically; one that doesn't is excluded automatically — see
19+
* The normal path is provider-agnostic: a candidate passes or fails on catalog
20+
* metadata (`freeType`, `tos`, `hardStopGuaranteed`) plus, for quota-based
21+
* types, a `FreeAccessState` resolved elsewhere. Higher-precedence facts are
22+
* admitted before that path: local operator policy, connection-scoped live free
23+
* evidence, and Nous Portal's provider-owned `:free` variants. Everything else fails closed — see
2424
* `docs/routing/STRICT_ZERO_COST.md`.
2525
*
2626
* ## Connection safety (fixed after code review, see `docs/routing/STRICT_ZERO_COST.md`)
@@ -81,6 +81,7 @@ export interface StrictZeroCostCandidate {
8181
model: string;
8282
connectionId: string | null;
8383
allowedConnectionIds?: string[];
84+
freeConnectionIds?: string[];
8485
}
8586

8687
export interface StrictZeroCostOptions {
@@ -109,6 +110,13 @@ export interface StrictZeroCostOptions {
109110
maxStateAgeMs: number;
110111
/** `now` injection for deterministic tests; defaults to `Date.now`. */
111112
now?: () => number;
113+
/** Explicit local policy wins over catalog/live inference in either direction. */
114+
resolveOperatorTier?: (
115+
provider: string,
116+
model: string
117+
) => "free" | "cheap" | "premium" | undefined;
118+
/** Whether this connection's model discovery was live-verified recently enough. */
119+
isDiscoveryEvidenceFresh?: (provider: string, connectionId: string, maxAgeMs: number) => boolean;
112120
/**
113121
* The free-model catalog to look candidates up against. Defaults to the
114122
* real, live `FREE_MODEL_BUDGETS` — overridable so tests can prove the
@@ -129,9 +137,19 @@ export function findBudgetEntry(
129137
return catalog.find((m) => m.provider === candidate.provider && m.modelId === candidate.model);
130138
}
131139

140+
function candidateConnectionIds(candidate: StrictZeroCostCandidate): string[] {
141+
return candidate.connectionId ? [candidate.connectionId] : (candidate.allowedConnectionIds ?? []);
142+
}
143+
144+
/** Nous Portal rotates price-locked free variants independently of releases. */
145+
function isNousPortalFreeVariant(candidate: StrictZeroCostCandidate): boolean {
146+
return candidate.provider === "nous-research" && candidate.model.toLowerCase().endsWith(":free");
147+
}
148+
132149
/** Why the guard cannot trust a candidate right now. */
133150
export type StrictZeroCostExclusionReason =
134151
| "not-in-catalog"
152+
| "operator-non-free"
135153
| "regime-not-free"
136154
| "no-hard-stop"
137155
| "contradictory-noauth"
@@ -193,7 +211,14 @@ export function evaluateCandidateConnections(
193211
candidate: StrictZeroCostCandidate,
194212
budgetEntry: FreeModelBudget | undefined,
195213
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
196-
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
214+
options: Pick<
215+
StrictZeroCostOptions,
216+
| "minRemainingAllowance"
217+
| "maxStateAgeMs"
218+
| "now"
219+
| "resolveOperatorTier"
220+
| "isDiscoveryEvidenceFresh"
221+
>
197222
): string[] {
198223
const verdict = classifyStrictZeroCostCandidate(
199224
candidate,
@@ -214,9 +239,43 @@ export function classifyStrictZeroCostCandidate(
214239
candidate: StrictZeroCostCandidate,
215240
budgetEntry: FreeModelBudget | undefined,
216241
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
217-
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
242+
options: Pick<
243+
StrictZeroCostOptions,
244+
| "minRemainingAllowance"
245+
| "maxStateAgeMs"
246+
| "now"
247+
| "resolveOperatorTier"
248+
| "isDiscoveryEvidenceFresh"
249+
>
218250
): StrictZeroCostVerdict {
219-
if (!budgetEntry) return { outcome: "not-in-catalog" }; // paid, or genuinely unknown
251+
const directConnectionIds = candidateConnectionIds(candidate).filter(
252+
(connectionId) => connectionId !== SYNTHETIC_NOAUTH_CONNECTION_ID
253+
);
254+
const operatorTier = options.resolveOperatorTier?.(candidate.provider, candidate.model);
255+
if (operatorTier !== undefined) {
256+
if (operatorTier !== "free") return { outcome: "operator-non-free" };
257+
return directConnectionIds.length > 0
258+
? { outcome: "safe", safeConnectionIds: directConnectionIds }
259+
: { outcome: "no-connection" };
260+
}
261+
let hasStaleDiscoveryFreeEvidence = false;
262+
if ((candidate.freeConnectionIds?.length ?? 0) > 0) {
263+
const liveFree = directConnectionIds.filter(
264+
(id) =>
265+
candidate.freeConnectionIds!.includes(id) &&
266+
options.isDiscoveryEvidenceFresh?.(candidate.provider, id, options.maxStateAgeMs) === true
267+
);
268+
if (liveFree.length > 0) return { outcome: "safe", safeConnectionIds: liveFree };
269+
hasStaleDiscoveryFreeEvidence = true;
270+
}
271+
if (isNousPortalFreeVariant(candidate)) {
272+
return directConnectionIds.length > 0
273+
? { outcome: "safe", safeConnectionIds: directConnectionIds }
274+
: { outcome: "no-connection" };
275+
}
276+
277+
if (!budgetEntry)
278+
return { outcome: hasStaleDiscoveryFreeEvidence ? "state-unknown" : "not-in-catalog" };
220279

221280
const isGenuineNoAuthCandidate = candidate.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
222281
if (allowsNoAuthShortcut(budgetEntry.freeType)) {
@@ -242,21 +301,19 @@ export function classifyStrictZeroCostCandidate(
242301
// trust regardless of its answer.
243302
if (budgetEntry.hardStopGuaranteed !== true) return { outcome: "no-hard-stop" };
244303

245-
const candidateConnectionIds = candidate.connectionId
246-
? [candidate.connectionId]
247-
: (candidate.allowedConnectionIds ?? []);
304+
const connectionIds = candidateConnectionIds(candidate);
248305

249306
// No account at all to check. Reporting `state-unknown` here would send the
250307
// operator hunting a quota lookup that was never attempted; this is a wiring
251308
// problem, not a quota one.
252-
if (candidateConnectionIds.length === 0) return { outcome: "no-connection" };
309+
if (connectionIds.length === 0) return { outcome: "no-connection" };
253310

254311
const safe: string[] = [];
255312
// An observed exhaustion outranks a missing reading: one is a fact, the other
256313
// is the absence of one, and the operator needs the fact. Without this rule the
257314
// reason would depend on the order the connections happen to be listed in.
258315
let sawExhausted = false;
259-
for (const connectionId of candidateConnectionIds) {
316+
for (const connectionId of connectionIds) {
260317
if (connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID) continue; // never reachable here, defensive
261318
const state = classifyConnectionState(
262319
candidate.provider,

0 commit comments

Comments
 (0)