diff --git a/changelog.d/fixes/12763-free-model-metadata-discovery.md b/changelog.d/fixes/12763-free-model-metadata-discovery.md new file mode 100644 index 0000000000..fa8fd80543 --- /dev/null +++ b/changelog.d/fixes/12763-free-model-metadata-discovery.md @@ -0,0 +1 @@ +- **fix(models):** preserve free-model metadata (`isFree`) discovered live from a provider through synced-model normalization, so free models no longer lose that flag before reaching the UI/consumers ([#12763](https://github.com/diegosouzapw/OmniRoute/pull/12763)) — thanks @keeltrace diff --git a/src/lib/db/models/synced.ts b/src/lib/db/models/synced.ts index 1e282265a8..7eca78b327 100644 --- a/src/lib/db/models/synced.ts +++ b/src/lib/db/models/synced.ts @@ -22,6 +22,8 @@ export interface SyncedAvailableModel { alwaysThinking?: boolean; supportsTools?: boolean; supportsVideo?: boolean; + /** Discovery payload supplied free-economics evidence for this model. */ + isFree?: boolean; // #4264: image-input capability captured at sync time (e.g. OpenRouter // `architecture.input_modalities`/`modality`) so the catalog can surface vision. supportsVision?: boolean; @@ -95,6 +97,7 @@ function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | n ...(record.alwaysThinking === true ? { alwaysThinking: true } : {}), ...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}), ...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}), + ...(record.isFree === true ? { isFree: true } : {}), ...(record.supportsVision === true ? { supportsVision: true } : {}), }; } diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 5536c51544..f792416c72 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -19,6 +19,26 @@ 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 @@ -472,6 +492,18 @@ 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); deduped.set(id, { id, @@ -507,6 +539,7 @@ export function normalizeDiscoveredModels( ...(record.alwaysThinking === true ? { alwaysThinking: true } : {}), ...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}), ...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}), + ...(isFree ? { isFree: true } : {}), ...(supportsVision ? { supportsVision: true } : {}), }); } diff --git a/tests/unit/model-discovery-free-metadata.test.ts b/tests/unit/model-discovery-free-metadata.test.ts new file mode 100644 index 0000000000..d704a2ed9f --- /dev/null +++ b/tests/unit/model-discovery-free-metadata.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { normalizeSyncedAvailableModels } from "@/lib/db/models/synced"; +import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; + +test("normalizeDiscoveredModels records only free evidence present in discovery payloads", () => { + const models = normalizeDiscoveredModels( + [ + { id: "declared-free", isFree: true }, + { id: "zero-priced", pricing: { prompt: "0", completion: 0 } }, + { id: "rotating-model:free" }, + { id: "blank-price", pricing: { prompt: "", completion: "" } }, + { id: "paid", pricing: { prompt: "1", completion: "2" } }, + ], + "example-provider" + ); + const byId = new Map(models.map((model) => [model.id, model])); + + assert.equal(byId.get("declared-free")?.isFree, true); + assert.equal(byId.get("zero-priced")?.isFree, true); + assert.equal(byId.get("rotating-model:free")?.isFree, true); + assert.equal(byId.get("blank-price")?.isFree, undefined); + assert.equal(byId.get("paid")?.isFree, undefined); +}); + +test("normalizeSyncedAvailableModels preserves discovery free metadata", () => { + const [model] = normalizeSyncedAvailableModels([ + { id: "live-free", name: "Live Free", source: "imported", isFree: true }, + ]); + + assert.equal(model?.isFree, true); +});