feat(runtime): filter free provider models

This commit is contained in:
777genius 2026-05-22 21:01:22 +03:00
parent 1b836530e7
commit 0e8ccf2905
4 changed files with 208 additions and 20 deletions

View file

@ -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
</Badge>
) : null}
{model.free ? (
{freeModel ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{localRoute ? (
@ -1412,9 +1411,6 @@ function ModelBadges({
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-200">configured</Badge>
</>
) : null}
{builtinFreeRoute && !model.free ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{connectedRoute ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100">
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 (
<div className="mt-4 space-y-3 border-t border-white/10 pt-3">
@ -1993,6 +2021,27 @@ function ProviderModelList({
</Label>
</div>
) : null}
{hasFreeModels ? (
<div
className="flex h-10 items-center gap-2 rounded-md border border-white/10 px-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Checkbox
id={`runtime-provider-${provider.providerId}-free-only`}
checked={freeOnly}
disabled={disabled || state.modelsLoading}
onCheckedChange={(checked) => setFreeOnly(checked === true)}
className="size-3.5"
/>
<Label
htmlFor={`runtime-provider-${provider.providerId}-free-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
Free only
</Label>
</div>
) : null}
</div>
{state.modelsError ? (
@ -2010,9 +2059,7 @@ function ProviderModelList({
>
{!pickerOpen || state.modelsLoading ? <RuntimeProviderModelLoadingSkeleton /> : null}
{pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? (
<div className="text-sm text-[var(--color-text-muted)]">
{recommendedOnly ? 'No recommended models found.' : 'No models found.'}
</div>
<div className="text-sm text-[var(--color-text-muted)]">{emptyModelListMessage}</div>
) : null}
{pickerOpen
? visibleModels.map((model) => (

View file

@ -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<ProviderModelCatalogItem['metadata']>['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<TeamModelSelectorProps> = ({
}) => {
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<TeamModelSelectorProps> = ({
pricingInfo,
}),
isRecommended: isRecommendedTeamModelRecommendation(recommendation),
isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }),
};
});
}, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
@ -969,6 +990,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
() => 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<TeamModelSelectorProps> = ({
}
}, [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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
return recommendationOrder || left.index - right.index;
});
if (recommendedOnly) {
if (recommendedOnly || freeOnly) {
return concreteOptions;
}
@ -1135,6 +1170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
];
}, [
effectiveProviderId,
freeOnly,
modelQuery,
openCodeModelMetadata,
recommendedOnly,
@ -1220,6 +1256,15 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
) : null}
{!shouldShowOpenCodeCatalogLoading &&
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels) ? (
hasRecommendedOpenCodeModels ||
hasFreeOpenCodeModels) ? (
<div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover
@ -1785,6 +1831,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</Label>
</div>
) : null}
{hasFreeOpenCodeModels ? (
<div className="flex w-fit items-center gap-2">
<Checkbox
id="opencode-team-model-free-only"
checked={freeOnly}
onCheckedChange={(checked) => setFreeOnly(checked === true)}
className="size-3.5"
/>
<Label
htmlFor="opencode-team-model-free-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
Free only
</Label>
</div>
) : null}
</div>
) : null}
{effectiveProviderId === 'opencode' ? (
@ -1869,11 +1931,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)}
{visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{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}
</div>
) : null}
</div>

View file

@ -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<HTMLElement>('#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();

View file

@ -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<HTMLElement>('#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);