Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(providers):** Free-model detection reads each provider's own `/models` verdict through one shared predicate: `input`/`output` pricing and `free` tags count (Vercel AI Gateway, xKiro), explicit `isFree`/`free: false` vetoes a zero token price (Kilo, LLM Gateway), and a zero token price no longer marks models billed per request, image, second or minute as free ([#14930](https://github.com/diegosouzapw/OmniRoute/pull/14930)) — thanks @aldoeliacim
9 changes: 6 additions & 3 deletions src/app/api/v1/models/catalogOpenrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// model-type inference, and the free-model / display-name heuristics that shape
// OpenRouter entries in `getUnifiedModelsResponse`.

import { hasPayloadFreeEvidence } from "@/shared/utils/payloadFreeEvidence";

export function qualifyOpenRouterModelId(modelId: string): string {
return modelId.startsWith("openrouter/") ? modelId : `openrouter/${modelId}`;
}
Expand Down Expand Up @@ -30,10 +32,11 @@ export function isZeroPrice(value: unknown) {

export function isOpenRouterFreeModel(model: {
id?: string;
pricing?: { prompt?: string; completion?: string };
pricing?: unknown;
isFree?: unknown;
tags?: unknown;
}) {
if (typeof model.id === "string" && model.id.endsWith(":free")) return true;
return isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion);
return hasPayloadFreeEvidence(model);
}

export function getOpenRouterDisplayName(model: {
Expand Down
32 changes: 2 additions & 30 deletions src/lib/providerModels/modelDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardizatio
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { hasPayloadFreeEvidence } from "@/shared/utils/payloadFreeEvidence";

type JsonRecord = Record<string, unknown>;

Expand All @@ -20,26 +21,6 @@ function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}

function isZeroPrice(value: unknown): boolean {
if (typeof value === "number") return value === 0;
if (typeof value !== "string" || value.trim().length === 0) return false;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed === 0;
}

function hasLiveFreeEvidence(
id: string,
record: JsonRecord,
promptPrice: string | number | undefined,
completionPrice: string | number | undefined
): boolean {
return (
record.isFree === true ||
id.endsWith(":free") ||
(isZeroPrice(promptPrice) && isZeroPrice(completionPrice))
);
}

/**
* Resolve a positive integer token limit from a list of candidate values.
* Used to fall back across the differently-named context/output fields that
Expand Down Expand Up @@ -668,18 +649,9 @@ export function normalizeDiscoveredModels(
// models reached the catalog with no vision flag and vision-capable models
// (which work at request time) showed up as non-vision after import.
const supportsVision = detectVisionInput(record);
const pricing = asRecord(record.pricing);
const promptPrice =
typeof pricing.prompt === "string" || typeof pricing.prompt === "number"
? pricing.prompt
: undefined;
const completionPrice =
typeof pricing.completion === "string" || typeof pricing.completion === "number"
? pricing.completion
: undefined;
// Persist only evidence present in this discovery payload. Static catalog
// membership is intentionally not evidence about this connection's economics.
const isFree = hasLiveFreeEvidence(id, record, promptPrice, completionPrice);
const isFree = hasPayloadFreeEvidence({ ...record, id });

deduped.set(id, {
id,
Expand Down
25 changes: 8 additions & 17 deletions src/shared/utils/freeModels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "@omniroute/open-sse/config/freeModelCatalog";
import { getProviderById, resolveProviderId } from "@/shared/constants/providers";
import { globToRegex } from "@/shared/utils/globPattern";
import { hasPayloadFreeEvidence } from "@/shared/utils/payloadFreeEvidence";
import { AI_MODELS } from "@/shared/constants/models";

/**
Expand Down Expand Up @@ -71,17 +72,12 @@ export function providerHasFreeModels(providerId: string | undefined | null): bo
);
}

function isZeroPrice(value: unknown): boolean {
if (typeof value === "number") return value === 0;
if (typeof value !== "string") return false;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed === 0;
}

export interface FreeModelCandidate {
id?: string;
pricing?: { prompt?: string | number; completion?: string | number };
/** Raw `pricing` object from the provider's /models entry (any shape). */
pricing?: unknown;
isFree?: boolean;
tags?: unknown;
}

/** Shipped-catalog entry for this provider (id or alias): trusted on its own. */
Expand All @@ -93,20 +89,13 @@ function isCatalogFreeModel(provider: string, modelId: unknown): boolean {
);
}

/** Payload-supplied free signals: `isFree:true`, a `:free` id suffix, or zero prices. */
function hasPayloadFreeSignal(model: FreeModelCandidate): boolean {
if (model.isFree === true) return true;
if (typeof model.id === "string" && model.id.endsWith(":free")) return true;
return isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion);
}

/**
* Whether a single fetched model qualifies as free for the given provider (id or alias): a
* shipped-catalog entry, or a payload signal on a provider with a documented free tier.
*/
export function isFreeModel(provider: string, model: FreeModelCandidate): boolean {
if (isCatalogFreeModel(provider, model.id)) return true;
return providerHasFreeModels(provider) && hasPayloadFreeSignal(model);
return providerHasFreeModels(provider) && hasPayloadFreeEvidence(model);
}

/** Reusable free predicate for fetched payloads — provider must have a documented free tier. */
Expand All @@ -128,7 +117,8 @@ export const FREE_BADGE_STRICT_FLAG = "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER";
/**
* Whether the provider-page model list shows the "Free" badge for a model row.
*
* Default (`strict: false`) is the historical rule, unchanged: any truthy `free` field,
* Default (`strict: false`) is the historical rule: any truthy `free` field, an explicit
* `isFree === true` (live discovery evidence, honored on every provider as in strict mode),
* a `:free` id suffix, "free"/"grátis" in the display name, or `isFreeModel`.
*
* With `strict: true` (feature flag FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER) only badges
Expand All @@ -149,6 +139,7 @@ export function isModelFreeBadge(
if (!options.strict) {
return (
Boolean(model.free) ||
model.isFree === true ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") ||
isFreeModel(provider, { id: model.id, isFree: model.isFree as boolean | undefined })
Expand Down
97 changes: 97 additions & 0 deletions src/shared/utils/payloadFreeEvidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Payload free-evidence: whether a provider's own `/models` entry says the model costs nothing.
*
* One predicate for every place that reads a live catalog entry, so discovery, the
* import-only-free filter and the OpenRouter catalog cannot drift apart.
*
* Precedence, strongest first:
* 1. An explicit boolean flag (`isFree` / `is_free` / `free`, top-level or inside `pricing`)
* is the provider's own verdict and wins both ways: `false` vetoes a zero price.
* 2. A `:free` id suffix.
* 3. Zero per-token price (`prompt`/`completion` or `input`/`output`) with no positive price
* anywhere else in `pricing` (per-request, per-image, per-second, audio, ...).
* 4. A `free` tag, under the same no-positive-price condition.
*/

type JsonRecord = Record<string, unknown>;

const FREE_FLAG_KEYS = ["isFree", "is_free", "free"] as const;

/** Numeric fields inside `pricing` that are ratios/limits, not prices. */
const NON_PRICE_KEYS = new Set([
"discount",
"multiplier",
"coefficient",
"prompt_tokens_threshold",
"threshold",
]);

function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}

function toPrice(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value !== "string" || value.trim().length === 0) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

function explicitFreeFlag(entry: JsonRecord, pricing: JsonRecord | null): boolean | null {
for (const source of [entry, pricing]) {
if (!source) continue;
for (const key of FREE_FLAG_KEYS) {
if (typeof source[key] === "boolean") return source[key] as boolean;
}
}
return null;
}

function hasPositivePrice(value: unknown, depth = 0): boolean {
if (depth > 4) return false;
if (Array.isArray(value)) return value.some((item) => hasPositivePrice(item, depth + 1));
const record = asRecord(value);
if (record) {
return Object.entries(record).some(
([key, item]) => !NON_PRICE_KEYS.has(key) && hasPositivePrice(item, depth + 1)
);
}
if (typeof value === "boolean") return false;
const price = toPrice(value);
return price !== null && price > 0;
}

function firstPrice(pricing: JsonRecord, keys: readonly string[]): number | null {
for (const key of keys) {
const price = toPrice(pricing[key]);
if (price !== null) return price;
}
return null;
}

function hasFreeTag(entry: JsonRecord): boolean {
return (
Array.isArray(entry.tags) &&
entry.tags.some((tag) => typeof tag === "string" && tag.trim().toLowerCase() === "free")
);
}

export function hasPayloadFreeEvidence(entry: unknown): boolean {
const record = asRecord(entry);
if (!record) return false;
const pricing = asRecord(record.pricing);

const flag = explicitFreeFlag(record, pricing);
if (flag !== null) return flag;

if (typeof record.id === "string" && record.id.endsWith(":free")) return true;

if (pricing && hasPositivePrice(pricing)) return false;

if (pricing) {
const prompt = firstPrice(pricing, ["prompt", "input"]);
const completion = firstPrice(pricing, ["completion", "output"]);
if (prompt === 0 && completion === 0) return true;
}
return hasFreeTag(record);
}
16 changes: 16 additions & 0 deletions tests/unit/free-badge-provider-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ describe("A. flag off: historical badge rule unchanged", () => {
assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9:free" }), true);
});

it("keeps the badge for explicit isFree evidence persisted by discovery", () => {
// Strict mode honors `isFree === true`; the default rule must be a superset of it,
// including on registered providers outside the static free catalog (e.g. a gateway
// whose live /models payload prices the model at 0).
assert.equal(legacy(PAID_REGISTERED, { id: "stealth/pixel-canary", isFree: true }), true);
assert.equal(
isModelFreeBadge(
PAID_REGISTERED,
{ id: "stealth/pixel-canary", isFree: true },
{ strict: true }
),
true
);
assert.equal(legacy(PAID_REGISTERED, { id: "stealth/pixel-canary", isFree: "true" }), false);
});

it("does not badge a plain paid model", () => {
assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "GPT 9" }), false);
assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "Freeform writer" }), false);
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/model-discovery-free-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,55 @@ test("normalizeDiscoveredModels records only free evidence present in discovery
assert.equal(byId.get("paid")?.isFree, undefined);
});

test("normalizeDiscoveredModels reads Vercel AI Gateway input/output pricing and free tags", () => {
// Shape of https://ai-gateway.vercel.sh/v1/models: `pricing.input` / `pricing.output`
// (not OpenRouter's prompt/completion) plus an optional `tags` array.
const models = normalizeDiscoveredModels(
[
{ id: "stealth/pixel-canary", pricing: { input: "0", output: "0" } },
{ id: "poolside/laguna-s-2.1-free", pricing: { input: "0", output: "0" }, tags: ["free"] },
{ id: "vendor/tagged-no-price", tags: ["tool-use", "free"] },
{ id: "vendor/tagged-but-priced", pricing: { input: "0.1", output: "0.4" }, tags: ["free"] },
{ id: "vendor/half-free", pricing: { input: "0", output: "0.4" } },
{ id: "vendor/paid", pricing: { input: "0.1", output: "0.4" }, tags: ["reasoning"] },
],
"vercel-ai-gateway"
);
const byId = new Map(models.map((model) => [model.id, model]));

assert.equal(byId.get("stealth/pixel-canary")?.isFree, true);
assert.equal(byId.get("poolside/laguna-s-2.1-free")?.isFree, true);
assert.equal(byId.get("vendor/tagged-no-price")?.isFree, true);
// A published non-zero price outranks a tag: never badge a model that bills.
assert.equal(byId.get("vendor/tagged-but-priced")?.isFree, undefined);
assert.equal(byId.get("vendor/half-free")?.isFree, undefined);
assert.equal(byId.get("vendor/paid")?.isFree, undefined);
});

test("normalizeDiscoveredModels honors provider free:false vetoes and non-token billing", () => {
// Real shapes: Kilo marks per-generation models isFree:false with 0/0 token prices,
// LLM Gateway marks routers free:false, EUrouter bills per request.
const models = normalizeDiscoveredModels(
[
{
id: "google/lyria-3-pro-preview",
isFree: false,
pricing: { prompt: "0", completion: "0" },
},
{ id: "auto", free: false, pricing: { prompt: "0", completion: "0", request: "0" } },
{ id: "deepseek-ocr-2", pricing: { prompt: "0", completion: "0", request: "0.02" } },
{ id: "agnes-3-0-flash", pricing: { free: true, input: null, output: null } },
],
"generic-gateway"
);
const byId = new Map(models.map((model) => [model.id, model]));

assert.equal(byId.get("google/lyria-3-pro-preview")?.isFree, undefined);
assert.equal(byId.get("auto")?.isFree, undefined);
assert.equal(byId.get("deepseek-ocr-2")?.isFree, undefined);
assert.equal(byId.get("agnes-3-0-flash")?.isFree, true);
});

test("normalizeSyncedAvailableModels preserves discovery free metadata", () => {
const [model] = normalizeSyncedAvailableModels([
{ id: "live-free", name: "Live Free", source: "imported", isFree: true },
Expand Down
Loading
Loading