Files
OmniRoute/open-sse/config/freeModelCatalog.ts
Dizzle c472dbccc4 fix(free-models): stop reporting discontinued Pollinations models as free (#11441)
Merged into release/v3.8.51 via batch validation (combined tree): vitest autoCombo suite 32/32 incl. free-regime-not-read-by-predicate, static gates green (file-size / complexity / cognitive / changelog-integrity / typecheck:core). Compiler-checked FREE_REGIME_TRAITS table eliminating the discontinued-as-free routing bug — excellent work, thanks @maxmad64bis!
2026-08-25 01:37:45 -03:00

188 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TosVerdict } from "./freeTierCatalog.ts";
export { FREE_MODEL_BUDGETS } from "./freeModelCatalog.data.ts";
import { FREE_MODEL_BUDGETS } from "./freeModelCatalog.data.ts";
export type FreeModelFreeType =
| "recurring-daily"
| "recurring-monthly"
| "recurring-credit"
| "recurring-uncapped"
| "one-time-initial"
| "keyless"
| "discontinued";
export interface FreeModelBudget {
provider: string;
modelId: string;
displayName: string;
monthlyTokens: number;
creditTokens: number;
freeType: FreeModelFreeType;
poolKey: string | null;
tos: TosVerdict;
/**
* Provider states it may train on user prompts. Surfaced in the UI so the
* privacy cost of a "free" tier is visible next to the quota. Kilo's gateway
* reports this per model as `mayTrainOnYourPrompts` on its public catalog.
*/
trainsOnPrompts?: boolean;
/**
* True only when the provider's own published terms document that exceeding
* the free allowance is a hard stop (request refused / rate-limited) and NOT
* automatic pay-as-you-go billing — e.g. an explicit "no credit card
* required" claim on the provider's pricing page. This is a curated fact
* about the upstream provider, not something derivable from `freeType` or
* from any live API response, so it must be set by hand per entry with the
* source of the claim in a comment. Leave unset (undefined) whenever this
* isn't independently documented — `undefined` and `false` are both treated
* as "not guaranteed" by `strictZeroCostFilter.ts`; never default to `true`
* to grow the catalog. See STRICT_ZERO_COST in
* `open-sse/services/autoCombo/strictZeroCostFilter.ts`.
*/
hardStopGuaranteed?: boolean;
}
export interface FreeModelTotals {
/** Pool-deduped recurring tokens/month — the headline "steady" number. */
steadyRecurringTokens: number;
/** Steady + recurring credit grants (e.g. monthly $-credit plans). */
steadyWithRecurringCreditsTokens: number;
/** Steady + recurring + one-time signup credits — first-month only. */
firstMonthRealisticTokens: number;
/**
* Extra recurring tokens/month unlocked by a one-time small deposit
* (e.g. OpenRouter: 50→1000 req/day after a $10 lifetime top-up).
* Reported separately so it never inflates the steady headline.
*/
boostMonthlyTokens: number;
/**
* Providers that are permanently free but publish NO token cap
* (rate/concurrency-limited). Real access, but un-quantifiable — listed,
* never summed into the headline (avoids the rate-limit×24/7 inflation).
*/
uncappedProviders: string[];
modelCount: number;
poolCount: number;
perModel: FreeModelBudget[];
headline: string;
}
const RECURRING = new Set<FreeModelFreeType>(["recurring-daily", "recurring-monthly", "keyless"]);
/**
* What each free-tier regime engages for "can I route here without paying?".
* Exhaustive by construction: adding a member to `FreeModelFreeType` will not
* compile until it is classified here. `discontinued` is the one regime a
* provider uses to retire a free tier behind a paid key — it does NOT grant
* free access, and the shared predicate (`isFreeModel`) must read this instead
* of treating every catalogued id as free. `RECURRING` (above) answers a
* different question (which regimes feed the headline token totals) and is left
* independent on purpose — deriving it from this table would silently change
* the homepage totals.
*/
const FREE_REGIME_TRAITS = {
"recurring-daily": { grantsFreeAccess: true },
"recurring-monthly": { grantsFreeAccess: true },
"recurring-credit": { grantsFreeAccess: true },
"recurring-uncapped": { grantsFreeAccess: true },
"one-time-initial": { grantsFreeAccess: true },
keyless: { grantsFreeAccess: true },
discontinued: { grantsFreeAccess: false },
} satisfies Record<FreeModelFreeType, { grantsFreeAccess: boolean }>;
export function grantsFreeAccess(freeType: FreeModelFreeType): boolean {
return FREE_REGIME_TRAITS[freeType].grantsFreeAccess;
}
/**
* Deposit-unlock boosts: a one-time small top-up that permanently raises a
* provider's recurring free quota. Kept OUT of the steady headline and surfaced
* as a separate "unlock more" figure. Keyed by the provider's recurring poolKey.
*/
export const FREE_TIER_BOOSTS: Record<
string,
{ provider: string; boostMonthlyTokens: number; note: string }
> = {
"openrouter-free": {
provider: "openrouter",
boostMonthlyTokens: 24_000_000,
note: "A one-time $10 lifetime top-up raises the free pool from 50 to 1000 requests/day (~24M tokens/month).",
},
};
function fmt(n: number): string {
return n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : Math.round(n / 1e6) + "M";
}
// Sum a per-model numeric field, counting each shared pool once (max within the pool);
// poolKey null => the model is independent and counts on its own.
function dedupedSum(
models: FreeModelBudget[],
pick: (m: FreeModelBudget) => number,
include: (m: FreeModelBudget) => boolean
): number {
const poolMax = new Map<string, number>();
let loose = 0;
for (const m of models) {
if (!include(m)) continue;
const key = m.poolKey;
if (key) poolMax.set(key, Math.max(poolMax.get(key) ?? 0, pick(m)));
else loose += pick(m);
}
for (const v of poolMax.values()) loose += v;
return loose;
}
export function computeFreeModelTotals(opts: { excludeTosAvoid?: boolean } = {}): FreeModelTotals {
const models = FREE_MODEL_BUDGETS.filter((m) => !(opts.excludeTosAvoid && m.tos === "avoid"));
const steadyRecurringTokens = dedupedSum(
models,
(m) => m.monthlyTokens,
(m) => RECURRING.has(m.freeType)
);
const recurringCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => m.freeType === "recurring-credit"
);
const oneTimeCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => m.freeType === "one-time-initial"
);
const steadyWithRecurringCreditsTokens = steadyRecurringTokens + recurringCredits;
const firstMonthRealisticTokens = steadyWithRecurringCreditsTokens + oneTimeCredits;
const poolCount = new Set(
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
).size;
// Deposit-unlock boost: sum the FREE_TIER_BOOSTS whose pool still has a live
// recurring model in the (optionally ToS-filtered) set.
const livePools = new Set(
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
);
const boostMonthlyTokens = Object.entries(FREE_TIER_BOOSTS)
.filter(([pool]) => livePools.has(pool))
.reduce((s, [, b]) => s + b.boostMonthlyTokens, 0);
// Permanently-free-but-uncapped providers (real access, no published cap).
const uncappedProviders = [
...new Set(models.filter((m) => m.freeType === "recurring-uncapped").map((m) => m.provider)),
].sort();
return {
steadyRecurringTokens,
steadyWithRecurringCreditsTokens,
firstMonthRealisticTokens,
boostMonthlyTokens,
uncappedProviders,
modelCount: models.length,
poolCount,
perModel: models.slice().sort((a, b) => b.monthlyTokens - a.monthlyTokens),
headline: `~${fmt(steadyRecurringTokens)} documented free tokens/month (steady), up to ~${fmt(firstMonthRealisticTokens)} in your first month with signup credits`,
};
}