From f4fae62ef8ebc0dcb1a43ae1a79ba6b4a39195c5 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 07:19:23 +0900 Subject: [PATCH 1/4] feat(catalog): classify discovered model pricing and filter free models Live /models rows already carried `pricing`, but nothing downstream kept it: catalogHintsFromModelsApiItem returned only window / modalities / reasoning / capabilities, so CatalogModel had no cost field and both Dashboard catalog lists could only substring-search. The "Free" UI that existed was PROVIDER tier (freeTier / keyOptional), which is why OpenRouter -- freeTier unset, paid and :free slugs mixed in one catalog -- could not be narrowed to \$0 models. discoveredPricingStatus reads the provider's own prompt/completion (or input/output) rates and classifies free, paid, or unknown. It is fail-closed: only a complete pair of non-negative numeric rates classifies at all, so a missing, one-sided, non-numeric, or negative rate stays unknown and is excluded from a free-only filter. Showing a paid model under a Free filter spends the user's money; hiding a free one costs a click. A :free id suffix is not evidence -- Nous ships :free slugs on a provider whose freeTier is false on purpose -- and the operator's own modelCosts overlay is an estimate they typed, not a published price, so neither reaches the field. catalogHintsFromModelsApiItem omits the hint when the classification is unknown, following its existing contract that an unknown property is absent rather than present-and-empty; several provider contract tests deep-equal that object. listManagementModelRows already spreads the CatalogModel, so /api/models carries the field, and deriveEntry builds Codex entries from an explicit field list, so it is never serialized into the catalog. Consumers: a per-provider Free switch on the Models page and on the provider workspace inventory, both sharing one predicate in models-shared so they cannot drift, applied before search, the enabled-first sort, and the page slice -- filtering after the slice would strand free models behind Show more on a 200-row list. The switch appears only where discovery actually returned prices, so a provider that publishes none does not get a control that can only empty its own list. `ocx models live --free-only` reads the same field; note the issue's `ocx model list --free-only` does not exist as a command. Closes #3666. --- .../provider-workspace/ProviderModels.tsx | 19 +++- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Models.tsx | 33 ++++++- gui/src/pages/models-shared.ts | 33 +++++++ scripts/test-layout/layout.json | 3 + src/cli/models-runtime.ts | 11 ++- src/codex/catalog/parsing.ts | 12 +++ src/codex/catalog/provider-fetch.ts | 51 ++++++++++ tests/cli/cli-models-free-only.test.ts | 67 +++++++++++++ .../catalog-free-pricing-status.test.ts | 93 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 + tests/gui/models-free-filter.test.ts | 54 +++++++++++ 20 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 tests/cli/cli-models-free-only.test.ts create mode 100644 tests/codex-integration/catalog-free-pricing-status.test.ts create mode 100644 tests/gui/models-free-filter.test.ts diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index bb158ed7802..7b12814d9b8 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -1,8 +1,9 @@ /** Canonical inventory and revision-bound custom-definition operations for one provider. */ import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; +import { Switch } from "../../ui"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; -import type { ModelRow } from "../../pages/models-shared"; +import { filterFreeModelRows, modelPricingKnown, type ModelRow } from "../../pages/models-shared"; import { putModelVisibility } from "../../model-visibility"; import { readJsonOrThrow } from "../../fetch-json"; import { createBoundedFetch } from "../../bounded-fetch"; @@ -45,6 +46,8 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels }: ProviderModelsProps) { const t = useT(); const [query, setQuery] = useState(""); + // Free-only narrowing for this provider's inventory (#3666), mirroring the Models page. + const [freeOnly, setFreeOnly] = useState(false); const [draft, setDraft] = useState(""); const [ownershipEpoch, setOwnershipEpoch] = useState(0); const ownershipKey = JSON.stringify([apiBase, item.name, modelRevision, ownershipEpoch]); @@ -74,8 +77,14 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels const modelId = draft.trim(); const duplicate = !!modelId && (known.includes(modelId) || encodedModelIdCollides(modelId, known)); const visible = rows.filter(row => !row.disabled); + // Offered only where discovery actually returned per-token prices; a provider that publishes + // none would otherwise get a switch that can only empty its own inventory. + const pricingKnown = modelPricingKnown(visible); + // Absent pricingStatus is never free — the classifier omits it exactly when the provider's + // rates were missing, partial, or unusable. + const priced = filterFreeModelRows(visible, freeOnly); const normalizedQuery = query.trim().toLowerCase(); - const filtered = visible.filter(row => [row.id, row.namespaced].some(value => value.toLowerCase().includes(normalizedQuery))); + const filtered = priced.filter(row => [row.id, row.namespaced].some(value => value.toLowerCase().includes(normalizedQuery))); const labels = new Map(); for (const row of visible) labels.set(row.id, (labels.get(row.id) ?? 0) + 1); @@ -247,8 +256,12 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels } setQuery(event.target.value)} aria-label={t("pws.modelSearchPlaceholder")} /> + {pricingKnown &&
+ setFreeOnly(!freeOnly)} label={t("models.freeOnly")} showLabel /> +
} {modelRows !== null && visible.length === 0 ?

{t("pws.noModels")}

- : filtered.length === 0 && modelRows !== null ?

{t("pws.noModelMatch")}

+ : filtered.length === 0 && modelRows !== null + ?

{t(freeOnly && priced.length === 0 ? "models.noFreeMatch" : "pws.noModelMatch")}

: