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
29 changes: 29 additions & 0 deletions client/src/components/ProviderModelSelector.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const getToolUseModels = vi.fn();
vi.mock('../services/apiLocalLlm', () => ({ getToolUseModels: (...a) => getToolUseModels(...a) }));

import ProviderModelSelector from './ProviderModelSelector';
import { providerModeSelectionPolicy } from '../utils/providers.js';
import { __resetToolUseModelIdsCache } from '../hooks/useToolUseModelIds.js';
import SHIPPED_PROVIDERS from '../../../data.reference/providers.json';

Expand Down Expand Up @@ -171,6 +172,34 @@ describe('ProviderModelSelector', () => {
expect([...effortSelect.options].map((option) => option.value)).toEqual(['', 'low']);
});

it('keeps a saved TUI pin visible with a reason under a CLI/API-only caller policy', () => {
// Acceptance for #6368: an ineligible saved value is never hidden and never
// silently replaced — the user must be able to SEE what is pinned and why it
// cannot run before choosing something else. The sibling CLI route stays a
// separate, selectable option, so collapsing the pair would fail this too.
renderSelector({
providers: [
{ id: 'claude-code', name: 'Claude Code', type: 'cli' },
{ id: 'claude-code-tui', name: 'Claude Code TUI', type: 'tui' },
{ id: 'ollama', name: 'Ollama', type: 'api' },
],
selectedProviderId: 'claude-code-tui',
availableModels: [],
selectionPolicy: providerModeSelectionPolicy('cli-harness'),
});
const providerSelect = screen.getAllByRole('combobox')[0];
const options = [...providerSelect.options];
// The eligible CLI route and the INELIGIBLE SAVED PIN are both offered;
// an ineligible route nobody pinned is simply not offered at all.
expect(options.map((option) => option.value)).toEqual(['claude-code', 'claude-code-tui']);
expect(options.find((option) => option.value === 'claude-code').disabled).toBe(false);
const pinned = options.find((option) => option.value === 'claude-code-tui');
expect(pinned.disabled).toBe(true);
expect(pinned.textContent).toMatch(/not permitted/i);
// The select still SHOWS the saved pin rather than snapping to a legal one.
expect(providerSelect.value).toBe('claude-code-tui');
});

it('keeps a disallowed saved model visible only as a disabled stale option', () => {
renderSelector({
providers: [{ id: 'local', name: 'Local' }],
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isCronExpression, describeCron } from '../../../../utils/cronHelpers';
import ToggleSwitch from '../../../ToggleSwitch';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { INTERVAL_LABELS, setMetadataOverride } from './scheduleConstants';
import { providerModeSelectionPolicy } from '../../../../utils/providers.js';

const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalIntervalType, globalTaskMetadata, managedAgentOptions, fileIssuesCapable, defaultFileIssues, doWorkRequiresWorktree, inheritedProviderText, providers, providersLoaded = true, override, onUpdate }) {
const [updating, setUpdating] = useState(false);
Expand Down Expand Up @@ -215,7 +216,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
<div className="w-full sm:w-auto sm:min-w-[240px] sm:max-w-[360px] sm:flex-1">
<AppProviderPin
providers={providers}
selectionPolicy={taskType === 'issue-watcher' ? { provider: (provider) => provider.type === 'api' } : undefined}
selectionPolicy={taskType === 'issue-watcher' ? providerModeSelectionPolicy('direct-api') : undefined}
loading={!providersLoaded}
providerId={override?.providerId}
model={override?.model}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ProviderModelSelector from '../../../ProviderModelSelector';
import { providerModeSelectionPolicy } from '../../../../utils/providers.js';

// Compact provider/model/effort pins rendered directly on a schedule card, so
// the common "point this task at a different model and run it" loop doesn't
Expand Down Expand Up @@ -30,7 +31,7 @@ export default function TaskModelQuickControls({ pins, providers, loading = fals
alwaysShowModel
compact
highlightToolUse={!toolFree}
selectionPolicy={toolFree ? { provider: (provider) => provider.type === 'api' } : undefined}
selectionPolicy={toolFree ? providerModeSelectionPolicy('direct-api') : undefined}
loading={loading}
disabled={disabled || saving}
/>
Expand Down
2 changes: 1 addition & 1 deletion client/src/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ is where the server parity tests pin each browser mirror.
| `providerGateways` | `PROVIDER_GATEWAYS` / `gatewayForProvider` / `isGatewayBackedProvider` — the hosted OpenAI-compatible gateways (`orcarouter`, `openrouter`) an OpenCode CLI/TUI wrapper can front-end, inheriting its API key at spawn time from the sibling API provider whose id equals the gateway id. Reads the generic `gatewayBacked` marker and, forever, the legacy per-gateway boolean. MIRROR of `server/lib/providerGateways.js`, which the browser cannot import; `server/lib/providerGateways.parity.test.js` reads this file as text and fails when the copies drift. |
| `providerEndpoints` | WHERE a provider's endpoint points. `isLocalEndpoint` (loopback), `isPrivateNetworkEndpoint` (RFC1918 / tailnet address, or a `.local` / `.ts.net` / single-label host — somewhere an unauthenticated OpenAI-compatible server is a normal setup rather than a missing API key), `isLocalInstanceProvider` (talks to a daemon on THIS machine; mirror of `server/lib/localProviderRuntime.js#isLocalInstanceEndpoint` — gate anything that explains a provider by inspecting the host PortOS runs on), `isFleetProvider` / `isFleetHostConfigured` (another machine inside the private network), and `localBackendForProvider` (`'ollama'` / `'lmstudio'` / `null` by id, endpoint or name — it matches by NAME, so pair it with `isLocalInstanceProvider` before claiming this machine's install state). |
| `providerModels` | Which MODEL and EFFORT a run resolves to, and what a picker may list. The configured-default sentinels (`CODEX_CONFIGURED_DEFAULT` …, `isConfiguredDefaultModel`, `configuredDefaultIn` — so a picker can render an option matching a sentinel-valued tier instead of a blank select) and `filterSelectableModels`; the per-CLI effort ladders, `effortLevelsForProvider`, `resolveCliEffort` (what a stored effort actually runs as, so the picker can name a clamped level), `effortSurvivingModel` and `seedModelEffort`; the Antigravity base-model ↔ effort-suffix split (`splitAntigravityModel`, `antigravityBaseModels`, `antigravityModelEffortLevels`, `withStaleAntigravityPin`); the account-aware option list (`providerModelList`, `resolveProviderModelOptions`, `effortAwareModelOptions`, `MODEL_SOURCE` — the one place a Codex-subscription account catalog enters a picker); `effectiveModelFor`; `mergeModelLists` / `mergeProviderUpdate`; and `generationControlsFor` (which temperature / top-p / thinking controls a provider forwards at all). Mirror of `server/lib/providerModels.js` (and `server/lib/opencodeConfig.js` for the controls); `server/lib/providerModels.mirror.test.js` reads this file as text and fails when a mirrored declaration drifts. |
| `providerSelection` | Which providers and models a picker may OFFER on this install: `isProviderHardwareCompatible` / `isProviderModelHardwareCompatible` / `filterHardwareCompatibleProviderModels` (server-annotated, fail-open), `selectableProviders` (the single rule `ProviderModelSelector` renders from), `filterRunnableProviders` (agent jobs need a CLI/TUI harness), the fail-closed tool-free local policy (`TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, `toolFreeLocalSelectionPolicy`) used by security-sensitive pickers, and the pr-reviewer postures (`PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, `enforcesPublicReviewPosture`, `publicReviewSelectionPolicy` — mirror of `server/lib/agentExecutionProfiles.js`: a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` and `publicReviewEnforcedPostures` on `GET /api/providers`, and the picker offers exactly the providers this install can run the stage on, so no vendor is named on either side). |
| `providerSelection` | Which providers and models a picker may OFFER on this install: `isProviderHardwareCompatible` / `isProviderModelHardwareCompatible` / `filterHardwareCompatibleProviderModels` (server-annotated, fail-open), `selectableProviders` (the single rule `ProviderModelSelector` renders from), `filterRunnableProviders` (agent jobs need a CLI/TUI harness), the fail-closed tool-free local policy (`TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, `toolFreeLocalSelectionPolicy`) used by security-sensitive pickers, and the pr-reviewer postures (`PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, `enforcesPublicReviewPosture`, `publicReviewSelectionPolicy` — mirror of `server/lib/agentExecutionProfiles.js`: a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` and `publicReviewEnforcedPostures` on `GET /api/providers`, and the picker offers exactly the providers this install can run the stage on, so no vendor is named on either side). Also the caller EXECUTION-MODE mirror (`CALLER_MODE_POLICY_MODES`, `callerModeList`, `providerModeSelectionPolicy`) of `server/lib/callerModePolicy.js` — the server enforces the policy on the pin, on `activeProvider` inheritance and on every fallback candidate; this only lets a picker SHOW the rule, keeping an ineligible saved value visible-but-disabled instead of hidden or silently replaced (parity: `providerModePolicy.parity.test.js`). |
| `localModelHeuristics` | What an untyped LOCAL model can do, judged from its id: `isEmbeddingModel` / `isVisionModel` / `isToolUseModel` (regexes mirrored from `server/lib/localModelHeuristics.js`; `server/lib/localModelHeuristics.mirror.test.js` reads this file as text and fails when a pattern stops matching the same ids), `isVisionCapableCliProvider`, `filterGenerationModels` (sentinels and embedding-only models dropped), `visionLocalModelFilter` and `localToolUseHint` / `withToolUseOptionLabel` (unioned with the server's authoritative per-provider capability maps from `useVisionModelIds` / `useToolUseModelIds`; tool-use is an annotation plus warning, never a filter), and `modelCapabilityInfo` (capability badges for a selected model without over-sharing a local runtime's answer with another provider). |
| `providerContextWindows` | How large a context window a provider/model gets for planning and where the number came from: the vendor constants and the `KNOWN_MODEL_CONTEXT_WINDOWS` ladder (`knownModelContextWindow`, `knownProviderContextWindow`), `catalogModelContextWindow` (the window the provider's own `/models` catalog reported), `resolveModelContextWindow` → `{ tokens, source }` with `CONTEXT_WINDOW_SOURCE` (`override` / `reported` / `assumed` — the state a card has to label rather than print as fact), `effectiveModelContextWindow`, and `modelOptionLabel` ("id (32K ctx)"). Mirror of the ladder in `server/services/stageRunner.js`; `server/services/stageRunner.mirror.test.js` reads this file as text and fails when the two resolve differently. |
| `providerReadiness` | Is a provider READY to run on this install, and why not. `credentialSource` (stored / inherited / env / subscription / none) and `providerCardState(provider, { runtime, status, keySetFor, envVarSet, codexAccount })` + `PROVIDER_CARD_STATE` — ready, benched, blocked on a missing prerequisite (CLI not installed / API key absent / empty credential env var / signed-out ChatGPT account), unknown, or simply switched off. Reads the SERVER's `missingPrerequisites` (published per provider on `GET /api/providers` from `server/lib/providerPrerequisites.js`, the same computation `getFallbackProvider` routes on) and adds only what the browser can see; unknown lookup values mean "not probed", never "missing". Distinct from `ProviderReadiness` / `GET /api/providers/readiness`, which probes the local daemon behind a provider. Also `isRunnerAllowedCommand(command, allowedCommands)` — would the CoS Agent Runner accept this command? Mirrors only the normalization in `server/cos-runner/allowedCommands.js` (the list arrives as `runnerAllowedCommands` on `GET /api/providers`); returns `null` for "list not fetched" so a failed fetch never renders a warning; pinned by `server/cos-runner/allowedCommands.parity.test.js`. `resolvesOutsidePortosPath` / `providerRuntimeKey` (the key a CLI runtime is published under by `GET /api/providers/runtimes`), `supportsModelRefresh` (`canRefreshModels === true`, the server's own answer) and `codexRoutingAdvisory` (the non-blocking "your `~/.codex/config.toml` re-points Codex" notice — server-only and machine-local, rendered in the UI and nowhere else). |
Expand Down
37 changes: 37 additions & 0 deletions client/src/utils/providerModePolicy.parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { CALLER_MODE_POLICIES } from '../../../server/lib/callerModePolicy.js';
import { CALLER_MODE_POLICY_MODES, callerModeList, providerModeSelectionPolicy } from './providerSelection.js';

/**
* The browser mirror only decides what a PICKER offers; the server decides what
* actually runs. They must agree, or a user saves a route the server refuses at
* spawn time (or, worse, the picker hides a route the server would happily run).
*/
describe('caller execution-mode policy — client/server parity', () => {
it('mirrors every server policy, name for name and mode for mode', () => {
expect(Object.keys(CALLER_MODE_POLICY_MODES).sort()).toEqual(Object.keys(CALLER_MODE_POLICIES).sort());
for (const [id, { allowedModes }] of Object.entries(CALLER_MODE_POLICIES)) {
expect(CALLER_MODE_POLICY_MODES[id]).toEqual([...allowedModes]);
}
});

it('permits nothing for an unknown policy name rather than everything', () => {
// Fail-closed in the same direction as the server, which throws: a typo has
// to be visible, never a silently permissive picker.
expect(callerModeList('typo')).toEqual([]);
const policy = providerModeSelectionPolicy('typo');
expect(policy.provider({ type: 'cli' })).toBe(false);
});

it('offers exactly the caller policy modes', () => {
const agent = providerModeSelectionPolicy('agent-harness');
expect(agent.provider({ type: 'cli' })).toBe(true);
expect(agent.provider({ type: 'tui' })).toBe(true);
expect(agent.provider({ type: 'api' })).toBe(false);
expect(agent.provider(null)).toBe(false);

const apiOnly = providerModeSelectionPolicy('direct-api');
expect(apiOnly.provider({ type: 'api' })).toBe(true);
expect(apiOnly.provider({ type: 'tui' })).toBe(false);
});
});
34 changes: 34 additions & 0 deletions client/src/utils/providerSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,37 @@ export const filterHardwareCompatibleProviderModels = (models, provider) =>
const modelId = typeof model === 'string' ? model : model?.id;
return isProviderModelHardwareCompatible(provider, modelId);
});

/**
* Caller EXECUTION-MODE policies, mirrored from
* `server/lib/callerModePolicy.js#CALLER_MODE_POLICIES`.
*
* The server is the authority — it enforces the same policy on the explicit
* pin, on `activeProvider` inheritance and on every fallback candidate. This
* mirror exists only so a picker can SHOW the rule rather than let a user save
* a route the server will refuse at run time. `providerModeSelectionPolicy`
* feeds `ProviderModelSelector`'s `selectionPolicy`, which keeps an ineligible
* saved value visible-but-disabled with a reason instead of hiding or silently
* replacing it. Pinned by `providerModePolicy.parity.test.js`.
*/
export const CALLER_MODE_POLICY_MODES = Object.freeze({
'agent-harness': Object.freeze(['cli', 'tui']),
'cli-harness': Object.freeze(['cli']),
'direct-api': Object.freeze(['api']),
'any-text': Object.freeze(['cli', 'tui', 'api']),
});

/** The allowed-mode list for a policy name, or the array itself when given one. */
export const callerModeList = (policy) =>
(Array.isArray(policy) ? policy : CALLER_MODE_POLICY_MODES[policy]) || [];

/**
* A `selectionPolicy` restricting the provider select to one caller's allowed
* execution modes. An unknown policy name yields an empty allowed list, which
* permits nothing — the same fail-closed direction the server takes, so a typo
* surfaces as a visibly blocked picker rather than a silently permissive one.
*/
export const providerModeSelectionPolicy = (policy) => {
const allowed = callerModeList(policy);
return { provider: (provider) => allowed.includes(provider?.type) };
};
Loading