feat(runtime): filter free provider models
This commit is contained in:
parent
1b836530e7
commit
0e8ccf2905
4 changed files with 208 additions and 20 deletions
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue