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
25 changes: 22 additions & 3 deletions gui/src/components/provider-workspace/ProviderModels.tsx
Original file line number Diff line number Diff line change
@@ -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, freeOnlyInForce, modelPricingKnown, type ModelRow } from "../../pages/models-shared";
import { putModelVisibility } from "../../model-visibility";
import { readJsonOrThrow } from "../../fetch-json";
import { createBoundedFetch } from "../../bounded-fetch";
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -74,8 +77,18 @@ 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.
// Lapses with the switch: `pricingKnown` hides the control when discovery stops returning
// prices, and a stale `freeOnly` would otherwise keep filtering an inventory in which nothing
// can classify as free.
const freeOnlyActive = freeOnlyInForce(freeOnly, visible);
const priced = filterFreeModelRows(visible, freeOnlyActive);
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<string, number>();
for (const row of visible) labels.set(row.id, (labels.get(row.id) ?? 0) + 1);

Expand Down Expand Up @@ -245,10 +258,16 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels
</div> : (!ready || busy) && <p className="muted" role="status">{t(!modelRowsReady || modelsLoading ? "pws.modelsLoading" : "pws.modelOwnershipLoading")}</p>}
{mutation && (mutation.outcome === "unconfirmed" || mutation.refreshPending) && !modelsLoadFailed && ownershipError !== ownershipKey &&
<button type="button" className="btn btn-ghost btn-sm" onClick={retry} disabled={requestPending}>{t("common.retry")}</button>}
{/* Above the search box, matching the Models page group: the same filter must not sit on
opposite sides of the search input on the two surfaces that offer it. */}
{pricingKnown && <div className="row">
<Switch on={freeOnly} onClick={() => setFreeOnly(!freeOnly)} label={t("models.freeOnly")} showLabel />
</div>}
<input ref={searchRef} type="search" className="input pws-model-search" placeholder={t("pws.modelSearchPlaceholder")}
value={query} onChange={event => setQuery(event.target.value)} aria-label={t("pws.modelSearchPlaceholder")} />
{modelRows !== null && visible.length === 0 ? <p className="muted">{t("pws.noModels")}</p>
: filtered.length === 0 && modelRows !== null ? <p className="muted" role="status">{t("pws.noModelMatch")}</p>
: filtered.length === 0 && modelRows !== null
? <p className="muted" role="status">{t(freeOnlyActive && priced.length === 0 ? "models.noFreeMatch" : "pws.noModelMatch")}</p>
: <ul className="pws-model-list">{filtered.slice(0, CHIP_RENDER_CAP).map(row => <ProviderModelChip key={row.namespaced}
row={row} disambiguate={(labels.get(row.id) ?? 0) > 1} copied={copiedId === row.namespaced}
isDefault={row.id === item.defaultModel} selected={row.native !== true && selectedSet.has(row.id)}
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ export const de: Record<TKey, string> = {
"models.loading": "Lädt…",
"models.search": "Modelle suchen…",
"models.showMore": "{n} weitere anzeigen",
"models.freeOnly": "Nur kostenlose",
"models.noFreeMatch": "Hier gibt es keine kostenlosen Modelle. Schalte „Nur kostenlose“ aus, um die übrigen zu sehen.",
"models.allowlistLabel": "Nur ausgewählte",
"models.allowlistHint": "Nur geprüfte Modelle gehen in den Katalog (leer = alle). Nützlich für Anbieter mit tausenden Modellen.",
"models.selectedCount": "{n} ausgewählt",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,8 @@ export const en = {
"models.loading": "Loading…",
"models.search": "Search models…",
"models.showMore": "Show {n} more",
"models.freeOnly": "Free only",
"models.noFreeMatch": "No free models here. Turn off Free only to see the rest.",
"models.allowlistLabel": "Only selected",
"models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.",
"models.selectedCount": "{n} selected",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,8 @@ export const fr: Record<TKey, string> = {
"models.loading": "Chargement…",
"models.search": "Rechercher des modèles…",
"models.showMore": "Afficher {n} de plus",
"models.freeOnly": "Gratuits uniquement",
"models.noFreeMatch": "Aucun modèle gratuit ici. Désactivez « Gratuits uniquement » pour voir les autres.",
"models.allowlistLabel": "Sélection uniquement",
"models.allowlistHint": "Seuls les modèles cochés sont inclus dans le catalogue (vide = tous). Utile pour les fournisseurs proposant des milliers de modèles.",
"models.selectedCount": "{n} sélectionnés",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ export const ja: Record<TKey, string> = {
"models.loading": "読み込み中…",
"models.search": "モデルを検索…",
"models.showMore": "さらに {n} 件表示",
"models.freeOnly": "無料のみ",
"models.noFreeMatch": "ここに無料モデルはありません。「無料のみ」をオフにすると残りが表示されます。",
"models.allowlistLabel": "選択のみ",
"models.allowlistHint": "チェックしたモデルのみカタログに送信します(空 = すべて)。数千のモデルを公開するプロバイダーで有用です。",
"models.selectedCount": "{n} 件選択",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ export const ko: Record<TKey, string> = {
"models.loading": "불러오는 중…",
"models.search": "모델 검색…",
"models.showMore": "{n}개 더 보기",
"models.freeOnly": "무료만",
"models.noFreeMatch": "여기에는 무료 모델이 없어요. ‘무료만’을 끄면 나머지가 보여요.",
"models.allowlistLabel": "선택만 노출",
"models.allowlistHint": "체크한 모델만 카탈로그에 노출돼요 (비우면 전체). 수천 개 모델을 노출하는 프로바이더에 유용해요.",
"models.selectedCount": "{n}개 선택",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,8 @@ export const ru: Record<TKey, string> = {
"models.loading": "Загрузка…",
"models.search": "Поиск моделей…",
"models.showMore": "Показать ещё {n}",
"models.freeOnly": "Только бесплатные",
"models.noFreeMatch": "Здесь нет бесплатных моделей. Отключите «Только бесплатные», чтобы увидеть остальные.",
"models.allowlistLabel": "Только выбранные",
"models.allowlistHint": "В каталог попадают только отмеченные модели (пусто = все). Полезно для провайдеров, предоставляющих тысячи моделей.",
"models.selectedCount": "Выбрано: {n}",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,8 @@ export const tr: Record<TKey, string> = {
"models.loading": "Yükleniyor…",
"models.search": "Modellerde ara…",
"models.showMore": "{n} tane daha göster",
"models.freeOnly": "Yalnızca ücretsiz",
"models.noFreeMatch": "Burada ücretsiz model yok. Diğerlerini görmek için „Yalnızca ücretsiz“ seçeneğini kapatın.",
"models.allowlistLabel": "Sadece seçilenler",
"models.allowlistHint": "Sadece işaretli modeller kataloğa gönderilir.",
"models.selectedCount": "{n} seçildi",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ export const zhTW: Record<TKey, string> = {
"models.loading": "載入中…",
"models.search": "搜尋模型…",
"models.showMore": "再顯示 {n} 個",
"models.freeOnly": "僅免費",
"models.noFreeMatch": "這裡沒有免費模型。關閉「僅免費」即可看到其餘模型。",
"models.allowlistLabel": "僅所選",
"models.allowlistHint": "僅勾選的模型進入目錄(留空 = 全部)。適用於暴露成千上萬模型的供應商。",
"models.selectedCount": "已選 {n} 個",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,8 @@ export const zh: Record<TKey, string> = {
"models.loading": "加载中…",
"models.search": "搜索模型…",
"models.showMore": "再显示 {n} 个",
"models.freeOnly": "仅免费",
"models.noFreeMatch": "这里没有免费模型。关闭“仅免费”即可看到其余模型。",
"models.allowlistLabel": "仅所选",
"models.allowlistHint": "仅勾选的模型进入目录(留空 = 全部)。适用于暴露成千上万模型的提供商。",
"models.selectedCount": "已选 {n} 个",
Expand Down
59 changes: 52 additions & 7 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ import {
THREAD_OPTIONS,
writeCollapsedProviders,
discoveryFailureLabel,
filterFreeModelRows,
freeOnlyInForce,
modelPricingKnown,
REASONING_EFFORT_LEVELS,
type ModelRow,
type ProviderContextCapsResponse,
Expand Down Expand Up @@ -242,6 +245,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
const [disabled, setDisabled] = useState<Set<string>>(() => new Set(cached?.disabled ?? []));
const [selectedModels, setSelectedModels] = useState<ProviderModelMap | null>(() => cached?.selectedModels ?? null);
const [search, setSearch] = useState<Record<string, string>>({});
// Per-provider Free-only narrowing (#3666). Session UX, not persisted config: it answers
// "what can I run for nothing right now", which is a question about this sitting, and the
// provider list it applies to changes underneath a stored value.
const [freeOnly, setFreeOnly] = useState<Record<string, boolean>>({});
const [limit, setLimit] = useState<Record<string, number>>({});
const [contextCaps, setContextCaps] = useState<Record<string, number>>(() => cached?.contextCaps ?? {});
const [contextCapValues, setContextCapValues] = useState<Record<string, number>>(() => cached?.contextCapValues ?? {});
Expand Down Expand Up @@ -1396,7 +1403,26 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
model.native === true,
disabled.has(model.namespaced),
);
const activeCount = rows.filter(isVisible).length;
const freeOnlyOn = freeOnly[provider] === true;
// The control is offered only where the provider actually published per-token prices.
// A provider whose rows are all unclassified — Ollama, a static catalog, anything with no
// pricing in /models — would otherwise get a switch whose only possible effect is to empty
// the list, which reads as a bug rather than as "this provider does not say".
const pricingKnown = modelPricingKnown(rows);
// Free-only narrows BEFORE the header counts, search, the enabled-first sort, and the PAGE
// slice below. Filtering after the slice would leave free models stranded behind Show more
// on a 200-row OpenRouter list, which is the exact case the issue reports.
//
// `scoped` is the set every count and bulk action reads. Search is deliberately NOT part of
// it: the search box has always been a transient find-as-you-type that leaves the counts
// alone, while Free only is a narrowing the user holds on, so a header still reading the
// whole provider would claim more models than the list under it shows.
// Gated on `pricingKnown` through `freeOnlyInForce`: the switch below is hidden when the
// provider stops publishing prices, so a narrowing left on from an earlier render must lapse
// with it rather than empty the list behind a control that is no longer there.
const freeOnlyActive = freeOnlyInForce(freeOnlyOn, rows);
const scoped = filterFreeModelRows(rows, freeOnlyActive);
const activeCount = scoped.filter(isVisible).length;
const recentForProvider = modelDiscovery?.recentArrivals[provider] ?? [];
const recentIds = new Set(recentForProvider.map(row => row.id));
const capOn = contextCaps[provider] !== undefined;
Expand All @@ -1409,7 +1435,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
const capOptionSet = group.nativeProviderGroup ? NATIVE_CAP_OPTION_SET : CAP_OPTION_SET;
const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined;
const q = (search[provider] ?? "").trim().toLowerCase();
const filtered = q ? rows.filter(m => m.id.toLowerCase().includes(q)) : rows;
const filtered = q ? scoped.filter(m => m.id.toLowerCase().includes(q)) : scoped;
// Display-only: enabled models float to the top of each provider group so they
// stay findable in long lists. The sort is stable, so the server order is kept
// inside each partition, and this does not affect the picker order above
Expand All @@ -1420,16 +1446,20 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
const remaining = filtered.length - visible.length;
// An empty provider has nothing to send: keep both bulk buttons inert so we never PUT an
// empty target list (the management API rejects it with 400).
const hasRows = rows.length > 0;
// Bulk follows the same scoped set as the counts it sits beside: with Free only on, an
// "All on" that enabled the 197 paid rows the header is not counting would be the exact
// surprise the header fix exists to prevent. Pending stays keyed to the whole provider,
// because initial discovery is a provider state that no display filter can clear.
const hasRows = scoped.length > 0;
const selectionPending = rows.some(model => model.initialSelectionPending);
const allOn = !hasRows || rows.every(isVisible);
const allOff = !hasRows || rows.every(m => !isVisible(m));
const allOn = !hasRows || scoped.every(isVisible);
const allOff = !hasRows || scoped.every(m => !isVisible(m));
const bulkToggle = (enable: boolean) => {
if (!hasRows || selectionPending) return;
void applyVisibility(
"provider",
provider,
rows.map(m => ({ id: m.id, native: m.native === true })),
scoped.map(m => ({ id: m.id, native: m.native === true })),
enable,
);
};
Expand All @@ -1456,7 +1486,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
{t("models.discoveryFailedBadge")}
</span>
)}
<span className="muted mono text-label">{t("models.active", { active: activeCount, total: rows.length })}</span>
<span className="muted mono text-label">{t("models.active", { active: activeCount, total: scoped.length })}</span>
{recentForProvider.length > 0 && <span className="models-chip mono text-caption">{t("models.newCount", { count: recentForProvider.length })}</span>}
</button>
<div className="row models-provider-actions">
Expand Down Expand Up @@ -1650,6 +1680,21 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
{rows.length === 0 && (
<EmptyProviderHint liveModels={liveModels} discovery={discovery} showFailureBadge={false} />
)}
{pricingKnown && (
<div className="row models-provider-hint">
<Switch
on={freeOnlyOn}
onClick={() => setFreeOnly(prev => ({ ...prev, [provider]: !freeOnlyOn }))}
label={t("models.freeOnly")}
showLabel
/>
</div>
)}
{/* Reads `scoped`, not `filtered`: with a search term that matches nothing, the
honest message is the search one, not "this provider has no free models". */}
{freeOnlyActive && scoped.length === 0 && rows.length > 0 && (
<p className="muted text-label" role="status">{t("models.noFreeMatch")}</p>
)}
{rows.length > PAGE / 2 && (
<input
className="input"
Expand Down
47 changes: 47 additions & 0 deletions gui/src/pages/models-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface ModelRow {
displayNameOverride?: string;
displayNameSource?: "operator" | "provider" | "fallback";
manualPricing?: boolean;
/**
* Provider-published cost class from model discovery (#3666). Absent means unknown — either
* the provider publishes no per-token rates, or the row was cached by a build that predates
* the field. Absent is never treated as free.
*/
pricingStatus?: "free" | "paid";
inputModalities?: string[];
contextWindow?: number;
contextCap?: number;
Expand All @@ -46,6 +52,47 @@ export interface ModelRow {
reasoningEfforts?: string[];
}

/** The pricing shape both Free-only consumers read; keeps the helpers usable from either page. */
export type PricedRow = { pricingStatus?: "free" | "paid" };

/**
* Whether a Free-only control should be offered for this set of rows at all (#3666).
*
* A provider that publishes no per-token prices — Ollama, a static catalog, anything whose
* /models rows carry no usable rate pair — leaves every row unclassified, so a Free switch
* there could only ever empty the list. That reads as a broken filter rather than as "this
* provider does not say", so the control is hidden instead.
*/
export function modelPricingKnown(rows: readonly PricedRow[]): boolean {
return rows.some(row => row.pricingStatus !== undefined);
}

/**
* Apply the Free-only narrowing (#3666).
*
* Absent `pricingStatus` is never free: the discovery classifier omits the field exactly when
* the provider's rates were missing, one-sided, non-numeric, or negative, and a cached row from
* an older build has no field either. Both consumers call this BEFORE their own search, sort,
* and page slice, or free models stay stranded behind Show more on a long provider list.
*/
export function filterFreeModelRows<T extends PricedRow>(rows: readonly T[], freeOnly: boolean): T[] {
return freeOnly ? rows.filter(row => row.pricingStatus === "free") : [...rows];
}

/**
* Whether the Free-only narrowing is actually in force for this set of rows.
*
* The switch is offered only where discovery returned prices, but the operator's choice is
* component state that outlives the rows it was made against. When the evidence goes away —
* a refresh that comes back without pricing, a re-auth, a discovery fallback to a static
* catalog — the control disappears while the stale `true` keeps filtering, and every row is
* unclassified, so the list empties with no visible way to turn it off. Gate the filter on the
* same condition that gates the switch and the narrowing lapses with the control.
*/
export function freeOnlyInForce(freeOnly: boolean, rows: readonly PricedRow[]): boolean {
return freeOnly && modelPricingKnown(rows);
}

function containsDisplayNameControlCharacter(value: string): boolean {
return [...value].some(character => {
const codePoint = character.codePointAt(0)!;
Expand Down
Loading
Loading