From 0e8ccf290550c696ecd2c05a45e6bff28ef98966 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 21:01:22 +0300 Subject: [PATCH] feat(runtime): filter free provider models --- .../ui/RuntimeProviderManagementPanelView.tsx | 71 +++++++++++++++--- .../team/dialogs/TeamModelSelector.tsx | 74 +++++++++++++++++-- .../TeamModelSelectorDisabledState.test.ts | 15 ++++ ...RuntimeProviderManagementPanelView.test.ts | 68 +++++++++++++++++ 4 files changed, 208 insertions(+), 20 deletions(-) diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 9cff5066..014b390b 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -1336,8 +1336,8 @@ function ModelBadges({ }): JSX.Element | null { const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId); const localRoute = model.routeKind === 'configured_local'; - const builtinFreeRoute = model.routeKind === 'builtin_free'; const connectedRoute = model.routeKind === 'connected_provider'; + const freeModel = isFreeRuntimeProviderModel(model); const verified = model.proofState === 'verified' || model.availability === 'available' || @@ -1351,8 +1351,7 @@ function ModelBadges({ const unknown = model.accessKind === 'unknown_model' || model.accessKind === 'no_model'; if ( - !model.free && - !builtinFreeRoute && + !freeModel && !model.default && !usedForNewTeams && !modelRecommendation && @@ -1403,7 +1402,7 @@ function ModelBadges({ Used in team picker ) : null} - {model.free ? ( + {freeModel ? ( free ) : null} {localRoute ? ( @@ -1412,9 +1411,6 @@ function ModelBadges({ configured ) : null} - {builtinFreeRoute && !model.free ? ( - free - ) : null} {connectedRoute ? ( connected @@ -1441,6 +1437,19 @@ function ModelBadges({ ); } +function isFreeRuntimeProviderModel(model: RuntimeProviderModelDto): boolean { + const normalizedModelId = model.modelId.trim().toLowerCase(); + return ( + model.free || + model.routeKind === 'builtin_free' || + model.accessKind === 'builtin_free' || + normalizedModelId === 'opencode/big-pickle' || + normalizedModelId.includes(':free') || + normalizedModelId.endsWith('-free') || + normalizedModelId.endsWith('/free') + ); +} + function isUnknownOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean { return model.accessKind === 'unknown_model' || model.accessKind === 'no_model'; } @@ -1485,7 +1494,7 @@ function getOpenCodeModelSearchText(model: RuntimeProviderModelDto): string { model.proofState, model.availability, model.accessReason ?? '', - model.free ? 'free' : '', + isFreeRuntimeProviderModel(model) ? 'free' : '', model.default ? 'default' : '', model.requiresExecutionProof ? 'needs test needs probe' : '', recommendation?.label ?? '', @@ -1928,10 +1937,15 @@ function ProviderModelList({ }): JSX.Element { const pickerOpen = state.modelPickerProviderId === provider.providerId; const [recommendedOnly, setRecommendedOnly] = useState(false); + const [freeOnly, setFreeOnly] = useState(false); const hasRecommendedModels = useMemo( () => state.models.some((model) => isOpenCodeTeamModelRecommended(model.modelId)), [state.models] ); + const hasFreeModels = useMemo( + () => state.models.some((model) => isFreeRuntimeProviderModel(model)), + [state.models] + ); useEffect(() => { if (!hasRecommendedModels) { @@ -1939,11 +1953,18 @@ function ProviderModelList({ } }, [hasRecommendedModels]); + useEffect(() => { + if (!hasFreeModels) { + setFreeOnly(false); + } + }, [hasFreeModels]); + const visibleModels = useMemo( () => state.models .map((model, index) => ({ model, index })) .filter(({ model }) => !recommendedOnly || isOpenCodeTeamModelRecommended(model.modelId)) + .filter(({ model }) => !freeOnly || isFreeRuntimeProviderModel(model)) .sort((left, right) => { const recommendationOrder = compareOpenCodeTeamModelRecommendations( left.model.modelId, @@ -1952,8 +1973,15 @@ function ProviderModelList({ return recommendationOrder || left.index - right.index; }) .map(({ model }) => model), - [recommendedOnly, state.models] + [freeOnly, recommendedOnly, state.models] ); + const emptyModelListMessage = recommendedOnly + ? freeOnly + ? 'No recommended free models found.' + : 'No recommended models found.' + : freeOnly + ? 'No free models found.' + : 'No models found.'; return (
@@ -1993,6 +2021,27 @@ function ProviderModelList({
) : null} + {hasFreeModels ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + setFreeOnly(checked === true)} + className="size-3.5" + /> + +
+ ) : null} {state.modelsError ? ( @@ -2010,9 +2059,7 @@ function ProviderModelList({ > {!pickerOpen || state.modelsLoading ? : null} {pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? ( -
- {recommendedOnly ? 'No recommended models found.' : 'No models found.'} -
+
{emptyModelListMessage}
) : null} {pickerOpen ? visibleModels.map((model) => ( diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 77587525..c00ffee4 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -104,6 +104,7 @@ interface OpenCodeModelOptionMetadata { pricingInfo: OpenCodeModelPricingInfo | null; searchText: string; isRecommended: boolean; + isFree: boolean; } interface OpenCodeVirtualHeadingRow { @@ -220,6 +221,24 @@ function buildOpenCodeModelSearchText({ .toLowerCase(); } +function isFreeOpenCodeModelOption({ + option, + routeMetadata, + pricingInfo, +}: { + option: TeamRuntimeModelOption; + routeMetadata: NonNullable['opencode'] | null; + pricingInfo: OpenCodeModelPricingInfo | null; +}): boolean { + const badgeLabel = option.badgeLabel?.trim().toLowerCase(); + return ( + pricingInfo?.free === true || + routeMetadata?.routeKind === 'builtin_free' || + badgeLabel === 'free' || + isFreeOpenCodeModelRoute(option.value) + ); +} + function getOpenCodeModelGridColumnCount(width: number): number { const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0; if (safeWidth <= 0) { @@ -721,6 +740,7 @@ export const TeamModelSelector: React.FC = ({ }) => { const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const [recommendedOnly, setRecommendedOnly] = useState(false); + const [freeOnly, setFreeOnly] = useState(false); const [modelQuery, setModelQuery] = useState(''); const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false); const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState(''); @@ -958,6 +978,7 @@ export const TeamModelSelector: React.FC = ({ pricingInfo, }), isRecommended: isRecommendedTeamModelRecommendation(recommendation), + isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }), }; }); }, [effectiveProviderId, modelOptions, openCodeCatalogModelById]); @@ -969,6 +990,10 @@ export const TeamModelSelector: React.FC = ({ () => openCodeModelMetadata.some((metadata) => metadata.isRecommended), [openCodeModelMetadata] ); + const hasFreeOpenCodeModels = useMemo( + () => openCodeModelMetadata.some((metadata) => metadata.isFree), + [openCodeModelMetadata] + ); useEffect(() => { if (previousSelectedProviderIdRef.current === selectedProviderId) { @@ -984,6 +1009,12 @@ export const TeamModelSelector: React.FC = ({ } }, [effectiveProviderId, hasRecommendedOpenCodeModels, recommendedOnly]); + useEffect(() => { + if (freeOnly && (effectiveProviderId !== 'opencode' || !hasFreeOpenCodeModels)) { + setFreeOnly(false); + } + }, [effectiveProviderId, freeOnly, hasFreeOpenCodeModels]); + useEffect(() => { if (previousEffectiveProviderIdRef.current === effectiveProviderId) { return; @@ -1023,6 +1054,9 @@ export const TeamModelSelector: React.FC = ({ if (recommendedOnly && !metadata.isRecommended) { continue; } + if (freeOnly && !metadata.isFree) { + continue; + } const sourceInfo = metadata.sourceInfo; if (!sourceInfo) { @@ -1040,7 +1074,7 @@ export const TeamModelSelector: React.FC = ({ return Array.from(sourceOptions.values()).sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: 'base' }) ); - }, [effectiveProviderId, openCodeModelMetadata, recommendedOnly]); + }, [effectiveProviderId, freeOnly, openCodeModelMetadata, recommendedOnly]); useEffect(() => { if (selectedOpenCodeSourceIds.size === 0) { @@ -1105,6 +1139,7 @@ export const TeamModelSelector: React.FC = ({ const concreteOptions = openCodeModelMetadata .filter((metadata) => metadata.option.value.trim().length > 0) .filter((metadata) => !recommendedOnly || metadata.isRecommended) + .filter((metadata) => !freeOnly || metadata.isFree) .filter((metadata) => { if (selectedOpenCodeSourceIds.size === 0) { return true; @@ -1123,7 +1158,7 @@ export const TeamModelSelector: React.FC = ({ return recommendationOrder || left.index - right.index; }); - if (recommendedOnly) { + if (recommendedOnly || freeOnly) { return concreteOptions; } @@ -1135,6 +1170,7 @@ export const TeamModelSelector: React.FC = ({ ]; }, [ effectiveProviderId, + freeOnly, modelQuery, openCodeModelMetadata, recommendedOnly, @@ -1220,6 +1256,15 @@ export const TeamModelSelector: React.FC = ({ effectiveProviderId === 'opencode' && !shouldShowOpenCodeCatalogLoading && visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD; + const emptyModelListMessage = trimmedModelQuery + ? 'No models match this search.' + : effectiveProviderId === 'opencode' && recommendedOnly && freeOnly + ? 'No recommended free OpenCode models are available in the current runtime list.' + : effectiveProviderId === 'opencode' && freeOnly + ? 'No free OpenCode models are available in the current runtime list.' + : effectiveProviderId === 'opencode' && recommendedOnly + ? 'No recommended OpenCode models are available in the current runtime list.' + : 'No models are available in the current runtime list.'; const activeProviderDisabledReason = activeProviderSelectable ? null : getProviderDisabledReason(effectiveProviderId); @@ -1689,7 +1734,8 @@ export const TeamModelSelector: React.FC = ({ ) : null} {!shouldShowOpenCodeCatalogLoading && ((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) || - hasRecommendedOpenCodeModels) ? ( + hasRecommendedOpenCodeModels || + hasFreeOpenCodeModels) ? (
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? ( = ({
) : null} + {hasFreeOpenCodeModels ? ( +
+ setFreeOnly(checked === true)} + className="size-3.5" + /> + +
+ ) : null} ) : null} {effectiveProviderId === 'opencode' ? ( @@ -1869,11 +1931,7 @@ export const TeamModelSelector: React.FC = ({ )} {visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
- {trimmedModelQuery - ? 'No models match this search.' - : effectiveProviderId === 'opencode' && recommendedOnly - ? 'No recommended OpenCode models are available in the current runtime list.' - : 'No models are available in the current runtime list.'} + {emptyModelListMessage}
) : null} diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 9df6bf60..2f4b206a 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -610,6 +610,21 @@ describe('TeamModelSelector disabled Codex models', () => { expect(notRecommendedIndex).toBeGreaterThan(unavailableIndex); expect(host.textContent).toContain('Recommended only'); + expect(host.textContent).toContain('Free only'); + + const freeOnlyToggle = host.querySelector('#opencode-team-model-free-only'); + expect(freeOnlyToggle).not.toBeNull(); + + await act(async () => { + freeOnlyToggle?.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('openai/gpt-oss-120b:free'); + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('openai/gpt-oss-20b:free'); + expect(host.textContent).not.toContain('qwen/qwen3-coder-plus'); + expect(host.textContent).not.toContain('anthropic/claude-sonnet-4.6'); await act(async () => { root.unmount(); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index d865671d..5bf4bc51 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -1908,6 +1908,74 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.useModelForNewTeams).not.toHaveBeenCalled(); }); + it('filters provider model picker rows to free models', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const connectedProvider = { + ...createState().view!.providers[0], + state: 'connected' as const, + ownership: ['managed'] as const, + modelCount: 2, + actions: [], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...createState().view!, + providers: [connectedProvider], + }, + providers: [connectedProvider], + selectedProviderId: 'openrouter', + modelPickerProviderId: 'openrouter', + modelPickerMode: 'use', + models: [ + { + providerId: 'openrouter', + modelId: 'openrouter/anthropic/claude-haiku-4.5', + displayName: 'anthropic/claude-haiku-4.5', + sourceLabel: 'OpenRouter', + free: true, + default: false, + availability: 'untested', + routeKind: 'connected_provider', + }, + { + providerId: 'openrouter', + modelId: 'openrouter/anthropic/claude-sonnet-4.6', + displayName: 'anthropic/claude-sonnet-4.6', + sourceLabel: 'OpenRouter', + free: false, + default: false, + availability: 'untested', + routeKind: 'connected_provider', + }, + ], + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Free only'); + expect(host.textContent).toContain('anthropic/claude-haiku-4.5'); + expect(host.textContent).toContain('anthropic/claude-sonnet-4.6'); + + await act(async () => { + host.querySelector('#runtime-provider-openrouter-free-only')?.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('anthropic/claude-haiku-4.5'); + expect(host.textContent).not.toContain('anthropic/claude-sonnet-4.6'); + }); + it('keeps the model search input enabled while model results are loading', async () => { const host = document.createElement('div'); document.body.appendChild(host);