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
62 changes: 51 additions & 11 deletions client/src/components/ProviderModelSelector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
* authoritative capability fetch (`useToolUseModelIds`), so an unannotated
* picker costs nothing. No-op for cloud/API providers, whose ids don't encode
* their family.
* @param {{provider?: function, model?: function, effort?: function}} [props.selectionPolicy]
* Optional shared policy applied to all three option lists. Provider
* predicates receive `(provider)`, model predicates receive `(model, provider)`
* and effort predicates receive `(effort, provider, model)`. A selected value
* that no longer satisfies the policy remains visible but disabled so it can
* be cleared without hiding a stale saved pin.
*/
import { useId } from 'react';
import {
Expand Down Expand Up @@ -96,7 +102,8 @@ export default function ProviderModelSelector({
layout = 'row',
highlightToolUse = false,
effort,
onEffortChange
onEffortChange,
selectionPolicy
}) {
const providerSelectId = useId();
const modelSelectId = useId();
Expand All @@ -107,7 +114,11 @@ export default function ProviderModelSelector({
// scoped to a genuinely tool-incapable local pin.
// Resolve against the effective provider (the pin, or what a blank selection
// falls back to) — everything below describes what a run would actually use.
const selectedProvider = providers.find((p) => p.id === (effectiveProviderId ?? selectedProviderId));
const providerList = Array.isArray(providers) ? providers : [];
const providerAllowed = selectionPolicy?.provider;
const modelAllowed = selectionPolicy?.model;
const effortAllowed = selectionPolicy?.effort;
const selectedProvider = providerList.find((p) => p.id === (effectiveProviderId ?? selectedProviderId));
// A blank model ("Default model") isn't a no-op: the agent resolver then runs
// the provider's own defaultModel — which for an Ollama-backed provider can be
// a non-tool model that silently wedges the stage. So evaluate the EFFECTIVE
Expand All @@ -133,24 +144,40 @@ export default function ProviderModelSelector({
// pinned to a now-disabled provider still renders its value instead of
// silently blanking the select. This is the single DRY gate for every
// provider→model picker; callers may also pre-filter, which is idempotent.
const visibleProviders = providers.filter(
const visibleProviders = providerList.filter(
(p) => (p.enabled !== false || p.id === selectedProviderId)
&& (isProviderHardwareCompatible(p) || p.id === selectedProviderId)
&& (!providerAllowed || providerAllowed(p) || p.id === selectedProviderId)
);
const compatibleModels = filterHardwareCompatibleProviderModels(availableModels, selectedProvider);
const compatibleModels = filterHardwareCompatibleProviderModels(availableModels, selectedProvider)
.filter((model) => !modelAllowed || modelAllowed(model, selectedProvider));
const selectedModelIsUnavailable = Boolean(
selectedModel
&& !isProviderModelHardwareCompatible(selectedProvider, selectedModel)
);
const modelOptions = selectedModelIsUnavailable
const selectedModelIsDisallowed = Boolean(
selectedModel
&& modelAllowed
&& !modelAllowed(selectedModel, selectedProvider)
);
const modelOptions = (selectedModelIsUnavailable || selectedModelIsDisallowed)
&& !compatibleModels.some((model) => modelOption(model)?.value === selectedModel)
? [selectedModel, ...compatibleModels]
: compatibleModels;
const showModel = alwaysShowModel || modelOptions.length > 0;
// The effort select is opt-in (`onEffortChange`) AND self-hiding: EffortSelect
// renders null for a provider with no effort control, so gate the label+wrapper
// on the same predicate or a non-effort provider gets an orphaned label.
const showEffort = !!onEffortChange && !!effortLevelsForProvider(selectedProvider, effectiveModel);
const effortLevels = effortLevelsForProvider(selectedProvider, effectiveModel);
const visibleEffortLevels = effortLevels?.filter(
(level) => !effortAllowed || effortAllowed(level, selectedProvider, effectiveModel)
);
const selectedEffortIsDisallowed = Boolean(
effort
&& effortAllowed
&& !effortAllowed(effort, selectedProvider, effectiveModel)
);
const showEffort = !!onEffortChange && Boolean(visibleEffortLevels?.length || selectedEffortIsDisallowed);
// Picking a model with NO effort tiers (Antigravity's ladder is per-model) makes
// the select above disappear — so clear the effort with it, or the value stays in
// state with no UI left to change it and every submit still sends it. Owned here
Expand All @@ -159,7 +186,11 @@ export default function ProviderModelSelector({
onModelChange(value);
if (!onEffortChange || !effort) return;
const surviving = effortSurvivingModel(selectedProvider, value, effort);
if (surviving !== effort) onEffortChange(surviving);
const filteredSurviving = surviving && effortAllowed
&& !effortAllowed(surviving, selectedProvider, effectiveModelFor(selectedProvider, value))
? ''
: surviving;
if (filteredSurviving !== effort) onEffortChange(filteredSurviving);
};
// `row` was sized for two selects; the effort control makes it three, which is
// unreadable at phone width — stack until `sm` when it's showing.
Expand All @@ -180,10 +211,14 @@ export default function ProviderModelSelector({
>
{emptyProviderOption != null && <option value="">{emptyProviderOption}</option>}
{visibleProviders.map((p) => {
const unavailable = !isProviderHardwareCompatible(p);
const hardwareUnavailable = !isProviderHardwareCompatible(p);
const policyDisallowed = Boolean(providerAllowed && !providerAllowed(p));
const unavailable = hardwareUnavailable || policyDisallowed;
return (
<option key={p.id} value={p.id} disabled={unavailable}>
{p.name}{unavailable ? ' (unavailable on this machine)' : ''}
{p.name}{hardwareUnavailable
? ' (unavailable on this machine)'
: policyDisallowed ? ' (not permitted here)' : ''}
</option>
);
})}
Expand All @@ -205,13 +240,17 @@ export default function ProviderModelSelector({
{modelOptions.map(m => {
const opt = modelOption(m);
if (!opt) return null;
const unavailable = !isProviderModelHardwareCompatible(selectedProvider, opt.value);
const hardwareUnavailable = !isProviderModelHardwareCompatible(selectedProvider, opt.value);
const policyDisallowed = Boolean(modelAllowed && !modelAllowed(m, selectedProvider));
const unavailable = hardwareUnavailable || policyDisallowed;
const label = annotateToolUse
? withToolUseOptionLabel(opt.value, opt.label, selectedProvider, toolUseIdsByProvider)
: opt.label;
return (
<option key={opt.value} value={opt.value} disabled={unavailable}>
{unavailable ? `${label} (unavailable on this machine)` : label}
{hardwareUnavailable
? `${label} (unavailable on this machine)`
: policyDisallowed ? `${label} (not permitted here)` : label}
</option>
);
})}
Expand All @@ -237,6 +276,7 @@ export default function ProviderModelSelector({
value={effort || ''}
onChange={onEffortChange}
disabled={disabled}
optionFilter={effortAllowed}
className={SELECT_CLASS}
/>
</div>
Expand Down
36 changes: 36 additions & 0 deletions client/src/components/ProviderModelSelector.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,42 @@ describe('ProviderModelSelector', () => {
expect(labels).toEqual(['Provider One', 'Provider Two']);
});

it('applies one selection policy to providers, models, and effort options', () => {
const policy = {
provider: (provider) => provider.id === 'local',
model: (model) => (typeof model === 'string' ? model : model.id) === 'safe-model',
effort: (level) => level === 'low',
};
renderSelector({
providers: [{ id: 'local', name: 'Local', type: 'cli', command: 'codex', models: ['gpt-5'] }, { id: 'cloud', name: 'Cloud' }],
selectedProviderId: 'local',
selectedModel: 'safe-model',
availableModels: [{ id: 'safe-model', capabilities: ['chat'] }, { id: 'tool-model', capabilities: ['tools'] }],
effort: 'low',
onEffortChange: () => {},
selectionPolicy: policy,
});

const [providerSelect, modelSelect, effortSelect] = screen.getAllByRole('combobox');
expect([...providerSelect.options].map((option) => option.value)).toEqual(['local']);
expect([...modelSelect.options].map((option) => option.value)).toEqual(['safe-model']);
expect([...effortSelect.options].map((option) => option.value)).toEqual(['', 'low']);
});

it('keeps a disallowed saved model visible only as a disabled stale option', () => {
renderSelector({
providers: [{ id: 'local', name: 'Local' }],
selectedProviderId: 'local',
selectedModel: 'tool-model',
availableModels: ['safe-model', 'tool-model'],
selectionPolicy: { model: (model) => model !== 'tool-model' },
});
const modelSelect = screen.getAllByRole('combobox')[1];
expect([...modelSelect.options].map((option) => option.value)).toEqual(['tool-model', 'safe-model']);
expect(modelSelect.querySelector('option[value="tool-model"]').disabled).toBe(true);
expect(modelSelect.querySelector('option[value="tool-model"]').textContent).toMatch(/not permitted/i);
});

it('hides incompatible providers and models while preserving selected pins', () => {
renderSelector({
providers: [
Expand Down
17 changes: 14 additions & 3 deletions client/src/components/cos/EffortSelect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { FormField } from '../ui/FormField';
* provider-wide ladder.
* @param {string} props.value - Current effort ('' = provider default).
* @param {function} props.onChange - Called with the new effort string.
* @param {function} [props.optionFilter] - Optional `(effort, provider, model) => boolean`
* policy applied to the effort options. A selected disallowed value remains
* visible as a disabled/stale option so the caller can clear it.
* @param {string} [props.id] - Id for the <select>, when the caller owns the
* `<label htmlFor>`. Defaults to a generated one (used by the FormField mode).
* @param {string} [props.label] - Field label; enables the FormField wrapper.
Expand All @@ -36,6 +39,7 @@ export default function EffortSelect({
model = null,
value,
onChange,
optionFilter,
id: idProp,
label,
hint,
Expand All @@ -46,8 +50,9 @@ export default function EffortSelect({
}) {
const generatedId = useId();
const id = idProp || generatedId;
const levels = effortLevelsForProvider(provider, model);
if (!levels) return null;
const allLevels = effortLevelsForProvider(provider, model);
if (!allLevels) return null;
const levels = allLevels?.filter((level) => !optionFilter || optionFilter(level, provider, model));

// A stored effort can sit outside this provider's ladder — a task/stage
// pinned to claude `max` whose provider was later switched to Antigravity
Expand All @@ -58,6 +63,8 @@ export default function EffortSelect({
// the run uses the clamped level. Mirrors the stale-model option the pipeline
// stage's Model select already renders.
const outOfLadder = value && !levels.includes(value) ? value : null;
const outOfLadderAllowed = !outOfLadder || !optionFilter || optionFilter(outOfLadder, provider, model);
if (!levels.length && !outOfLadder) return null;
const clamped = outOfLadder ? resolveCliEffort(outOfLadder, provider, model) : null;

const select = (
Expand All @@ -72,8 +79,12 @@ export default function EffortSelect({
>
<option value="">Default effort</option>
{outOfLadder && (
<option value={outOfLadder}>
<option
value={outOfLadder}
disabled={!outOfLadderAllowed}
>
{clamped ? `${outOfLadder} (runs as ${clamped})` : `${outOfLadder} (not supported — ignored)`}
{!outOfLadderAllowed ? ' (not permitted here)' : ''}
</option>
)}
{levels.map(level => (
Expand Down
17 changes: 17 additions & 0 deletions client/src/components/cos/EffortSelect.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ describe('EffortSelect', () => {
expect(screen.getByRole('option', { name: 'bogus (not supported — ignored)' })).toBeInTheDocument();
});

it('keeps a policy-disallowed saved effort visible so it can be cleared', () => {
render(<EffortSelect
provider={CLAUDE}
value="max"
onChange={() => {}}
optionFilter={(level) => level === 'low'}
/>);
const select = screen.getByRole('combobox');
expect(select).toHaveValue('max');
expect(screen.getByRole('option', { name: 'max (runs as max) (not permitted here)' })).toBeDisabled();
expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([
'Default effort',
'max (runs as max) (not permitted here)',
'low',
]);
});

it('does not add the extra option when the stored value is in the ladder', () => {
render(<EffortSelect provider={CLAUDE} value="xhigh" onChange={() => {}} />);
const options = screen.getAllByRole('option').map(o => o.textContent);
Expand Down
Loading