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);