diff --git a/client/src/components/ProviderModelSelector.test.jsx b/client/src/components/ProviderModelSelector.test.jsx
index b7fa1e2e18..6ff4c0e1be 100644
--- a/client/src/components/ProviderModelSelector.test.jsx
+++ b/client/src/components/ProviderModelSelector.test.jsx
@@ -36,6 +36,21 @@ describe('ProviderModelSelector', () => {
expect(options).toEqual(['Provider One', 'Provider Two', 'm1', 'm2']);
});
+ it('keeps both execution modes independently selectable when the settings page groups their card', () => {
+ const onProviderChange = vi.fn();
+ const executionModes = [{ id: 'example-cli', type: 'cli' }, { id: 'example-tui', type: 'tui' }];
+ renderSelector({ providers: [
+ { id: 'example-cli', name: 'Example CLI', type: 'cli', enabled: true, executionModes },
+ { id: 'example-tui', name: 'Example TUI', type: 'tui', enabled: true, executionModes },
+ ], selectedProviderId: 'example-cli', onProviderChange });
+ const select = screen.getByRole('combobox', { name: 'Provider' });
+ expect([...select.options].map(option => [option.value, option.textContent])).toEqual([
+ ['example-cli', 'Example CLI'], ['example-tui', 'Example TUI'],
+ ]);
+ fireEvent.change(select, { target: { value: 'example-tui' } });
+ expect(onProviderChange).toHaveBeenCalledWith('example-tui');
+ });
+
it('renders every current Codex fallback choice, including Codex Spark', () => {
const codexModels = SHIPPED_PROVIDERS.providers.codex.models;
expect(codexModels).toContain('gpt-5.3-codex-spark');
diff --git a/client/src/components/providers/ProviderCard.jsx b/client/src/components/providers/ProviderCard.jsx
index 3484ddd9f2..60b751fcc4 100644
--- a/client/src/components/providers/ProviderCard.jsx
+++ b/client/src/components/providers/ProviderCard.jsx
@@ -90,6 +90,8 @@ export default function ProviderCard({
status,
isDefault,
providersById,
+ activeProviderId,
+ statuses = {},
runnerAllowedCommands,
testResult,
refreshing,
@@ -118,6 +120,9 @@ export default function ProviderCard({
onCodexCopyCode,
onCodexEnable,
}) {
+ const modes = (provider.executionModes || []).map(mode => providersById?.[mode.id]).filter(Boolean);
+ const unified = modes.length > 1;
+ const shellProvider = unified ? modes.find(isTuiProvider) : provider;
const style = CARD_STATE_STYLES[cardState.state];
// Non-blocking: it never touches `cardState`, only what the card SAYS about
// where this provider's runs actually go.
@@ -153,13 +158,13 @@ export default function ProviderCard({
to split, and it is narrower than the viewport by the sidebar. */}
-
{provider.name}
+
{unified ? provider.name.replace(/\b(CLI|TUI)\b\s*/i, '').trim() : provider.name}
- {provider.type.toUpperCase()}
+ {unified ? 'CLI / TUI' : provider.type.toUpperCase()}
{isDefault && (
- DEFAULT
+ DEFAULT{unified ? ` · ${provider.type.toUpperCase()}` : ''}
)}
{fleetProvider && (
@@ -249,11 +254,11 @@ export default function ProviderCard({
are secret, so they can't ride a URL anyway. `tuiCommandLine` is
the display half of the same resolution: it shows what will run,
and an older server that omits it simply renders no button. */}
- {isLaunchableTuiProvider(provider) && (
+ {isLaunchableTuiProvider(shellProvider) && (
Launch in Shell
@@ -291,7 +296,7 @@ export default function ProviderCard({
{provider.enabled ? 'Disable' : 'Enable'}
- {!isDefault && provider.enabled && (
+ {!unified && !isDefault && provider.enabled && (
onSetActive(provider.id)}
disabled={!subscriptionReady}
@@ -301,12 +306,28 @@ export default function ProviderCard({
)}
-
(
+
+ {provider.enabled && (
+ onSetActive(mode.id)}
+ disabled={mode.id === activeProviderId || (isCodexSubscriptionProvider(mode) && (!subscriptionAccountReady || mode.textTransportEnabled !== true))}
+ className="px-3 py-1.5 text-sm bg-port-accent/20 text-port-accent rounded disabled:opacity-50"
+ >
+ {mode.id === activeProviderId ? `${mode.type.toUpperCase()} default` : `Set ${mode.type.toUpperCase()} default`}
+
+ )}
+ onEdit(mode)} className="px-3 py-1.5 text-sm bg-port-border text-white rounded">
+ Edit {mode.type.toUpperCase()}
+
+
+ ))}
+ {!unified && onEdit(provider)}
className="px-3 py-1.5 text-sm bg-port-border hover:bg-port-border/80 text-white rounded transition-colors"
>
Edit
-
+ }
onDelete(provider.id)}
@@ -320,6 +341,17 @@ export default function ProviderCard({
{/* Card body — full width, below the header row rather than beside the
action buttons. */}
+ {unified && (
+
+
CLI and TUI share enablement and the model catalog. Edit a mode to configure its arguments and model defaults.
+ {modes.filter(mode => mode.id !== provider.id && statuses[mode.id]?.available === false).map(mode => (
+
+ {mode.type.toUpperCase()} benched: {statuses[mode.id].message || statuses[mode.id].reason}{' '}
+ onRecover(mode.id)} className="underline">Retry {mode.type.toUpperCase()}
+
+ ))}
+
+ )}
!isProviderHardwareCompatible(p));
+ const cards = providers.filter(provider => {
+ const modes = provider.executionModes || [{ id: provider.id }];
+ const representative = modes.find(mode => mode.id === activeProviderId) || modes[0];
+ return provider.id === representative.id;
+ });
+ const runnable = cards.filter(isProviderHardwareCompatible);
+ const unrunnable = cards.filter(p => !isProviderHardwareCompatible(p));
return {
providersById: byId,
runtimeByProviderId: runtimeById,
@@ -994,6 +999,8 @@ export default function AIProviders() {
status={statuses[provider.id]}
isDefault={provider.id === activeProviderId}
providersById={providersById}
+ activeProviderId={activeProviderId}
+ statuses={statuses}
runnerAllowedCommands={runnerAllowedCommands}
testResult={testResults[provider.id]}
refreshing={Boolean(refreshing[provider.id])}
diff --git a/client/src/pages/AIProviders.test.jsx b/client/src/pages/AIProviders.test.jsx
index 553a0d1147..77b171512e 100644
--- a/client/src/pages/AIProviders.test.jsx
+++ b/client/src/pages/AIProviders.test.jsx
@@ -20,6 +20,7 @@ const api = vi.hoisted(() => ({
getSampleProviders: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
+ setActiveProvider: vi.fn().mockResolvedValue({}),
getOrchestrationProfiles: vi.fn().mockResolvedValue({ profiles: [] }),
createRun: vi.fn().mockResolvedValue({ runId: 'run-1' }),
stopRun: vi.fn().mockResolvedValue({}),
@@ -122,6 +123,40 @@ describe('AIProviders page load error handling', () => {
localModels.value = { ctxById: {}, installed: { ollama: null, lmstudio: null } };
});
+ it('renders one CLI/TUI card with one install check, explicit default modes and a TUI shell link', async () => {
+ const executionModes = [{ id: 'example', type: 'cli' }, { id: 'example-tui', type: 'tui' }];
+ api.getProviders.mockResolvedValue({ activeProvider: 'example', providers: [
+ { id: 'example', name: 'Example CLI', type: 'cli', command: 'opencode', enabled: true, models: ['model-a'], executionModes },
+ { id: 'example-tui', name: 'Example TUI', type: 'tui', command: 'opencode', enabled: true, models: ['model-a'], tuiCommandLine: 'opencode', executionModes },
+ { id: 'example-api', name: 'Example API', type: 'api', endpoint: 'http://192.0.2.10:11434', enabled: true, models: ['remote-model'] },
+ ] });
+ api.getProviderRuntimes.mockResolvedValue({ runtimes: { opencode: missingRuntime } });
+ renderPage();
+ expect(await screen.findByRole('heading', { name: 'Example', exact: true })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Example API' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: /Install OpenCode CLI/ })).toHaveLength(1);
+ expect(screen.getByRole('link', { name: 'Launch in Shell' })).toHaveAttribute('href', '/shell?provider=example-tui');
+ expect(screen.getByRole('button', { name: 'CLI default' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Set TUI default' }));
+ await waitFor(() => expect(api.setActiveProvider).toHaveBeenCalledWith('example-tui'));
+ expect(await screen.findByRole('button', { name: 'TUI default' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Set CLI default' }));
+ await waitFor(() => expect(api.setActiveProvider).toHaveBeenLastCalledWith('example'));
+ });
+
+ it('gates each unified Codex default on that mode’s own transport consent', async () => {
+ const executionModes = [{ id: 'codex', type: 'cli' }, { id: 'codex-tui', type: 'tui' }];
+ api.getCodexAccount.mockResolvedValue({ readiness: { status: 'ready' } });
+ api.getCodexModels.mockResolvedValue({ models: null });
+ api.getProviders.mockResolvedValue({ activeProvider: null, providers: [
+ { id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', enabled: true, textTransportEnabled: true, executionModes },
+ { id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', enabled: true, textTransportEnabled: false, executionModes },
+ ] });
+ renderPage();
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Set CLI default' })).toBeEnabled());
+ expect(screen.getByRole('button', { name: 'Set TUI default' })).toBeDisabled();
+ });
+
it('offers an install button on the card of a provider whose CLI is missing', async () => {
api.getProviders.mockResolvedValue({
providers: [{ id: 'opencode-ollama', name: 'OpenCode Ollama', type: 'cli', command: 'opencode', args: ['run'], enabled: true }],
diff --git a/scripts/migrations/355-unify-provider-modes.js b/scripts/migrations/355-unify-provider-modes.js
new file mode 100644
index 0000000000..84177b254b
--- /dev/null
+++ b/scripts/migrations/355-unify-provider-modes.js
@@ -0,0 +1,20 @@
+/** Share CLI/TUI enablement without invalidating saved execution IDs or pins. */
+import { join } from 'node:path';
+import { readFile } from 'node:fs/promises';
+import { atomicWrite } from '../../server/lib/fileUtils.js';
+import { unifyProviderModes } from '../../server/lib/aiToolkit/internal/providerModes.js';
+
+export default {
+ async up({ rootDir }) {
+ const path = join(rootDir, 'data', 'providers.json');
+ const raw = await readFile(path, 'utf8').catch(error => {
+ if (error.code === 'ENOENT') return null;
+ throw error;
+ });
+ if (raw === null) return { updated: 0 };
+ const data = JSON.parse(raw);
+ const changed = unifyProviderModes(data);
+ if (changed) await atomicWrite(path, data);
+ return { updated: changed ? 1 : 0 };
+ },
+};
diff --git a/scripts/migrations/355-unify-provider-modes.test.js b/scripts/migrations/355-unify-provider-modes.test.js
new file mode 100644
index 0000000000..c3d9bc36e5
--- /dev/null
+++ b/scripts/migrations/355-unify-provider-modes.test.js
@@ -0,0 +1,34 @@
+import { afterEach, expect, it } from 'vitest';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import migration from './355-unify-provider-modes.js';
+let rootDir;
+afterEach(async () => { if (rootDir) await rm(rootDir, { recursive: true, force: true }); });
+it('unifies either-enabled siblings, preserves execution pins and separate connections, and is idempotent', async () => {
+ rootDir = await mkdtemp(join(tmpdir(), 'portos-provider-modes-'));
+ expect(await migration.up({ rootDir })).toEqual({ updated: 0 });
+ await mkdir(join(rootDir, 'data'));
+ const path = join(rootDir, 'data', 'providers.json');
+ const providers = {};
+ for (const [stem, cliEnabled, tuiEnabled] of [['first', false, true], ['second', true, false], ['off', false, false]]) {
+ providers[stem] = { id: stem, type: 'cli', command: 'example', enabled: cliEnabled, models: ['model-a'], defaultModel: 'model-a', args: ['--print'] };
+ providers[`${stem}-tui`] = { id: `${stem}-tui`, type: 'tui', command: 'example', enabled: tuiEnabled, models: ['model-b'], defaultModel: 'model-b', args: [] };
+ }
+ providers.remote = { id: 'remote', type: 'api', endpoint: 'http://192.0.2.10:11434', enabled: false, models: ['remote-model'] };
+ providers['remote-tui'] = { id: 'remote-tui', type: 'tui', command: 'example', enabled: true };
+ providers.custom = { id: 'custom', type: 'cli', command: 'example', envVars: { BACKEND: 'one' }, enabled: false };
+ providers['custom-tui'] = { id: 'custom-tui', type: 'tui', command: 'example', envVars: { BACKEND: 'two' }, enabled: true };
+ const before = structuredClone(providers);
+ await writeFile(path, JSON.stringify({ activeProvider: 'first-tui', providers }));
+ expect(await migration.up({ rootDir })).toEqual({ updated: 1 });
+ const result = JSON.parse(await readFile(path, 'utf8'));
+ expect(result.activeProvider).toBe('first-tui');
+ for (const stem of ['first', 'second', 'off']) {
+ for (const id of [stem, `${stem}-tui`]) {
+ expect(result.providers[id]).toEqual({ ...before[id], enabled: stem !== 'off', models: ['model-a', 'model-b'] });
+ }
+ }
+ for (const id of ['remote', 'remote-tui', 'custom', 'custom-tui']) expect(result.providers[id]).toEqual(before[id]);
+ expect(await migration.up({ rootDir })).toEqual({ updated: 0 });
+});
diff --git a/server/lib/aiToolkit/internal/providerModes.js b/server/lib/aiToolkit/internal/providerModes.js
new file mode 100644
index 0000000000..5ec32f1586
--- /dev/null
+++ b/server/lib/aiToolkit/internal/providerModes.js
@@ -0,0 +1,48 @@
+// Keep execution IDs stable: saved tasks and older peers still select a mode.
+// Pair only conventional sibling IDs with the same harness and connection.
+import { isDeepStrictEqual } from 'node:util';
+
+export function providerModeGroups(providers) {
+ const byId = new Map(providers.map(provider => [provider.id, provider]));
+ const paired = new Set();
+ const groups = [];
+ for (const tui of providers.filter(provider => provider.type === 'tui' && /-tui(?:-|$)/.test(provider.id))) {
+ const stem = tui.id.replace(/-tui(?=-|$)/, '');
+ const cli = [byId.get(stem), byId.get(`${stem}-cli`)].find(provider => provider?.type === 'cli');
+ if (!cli || paired.has(cli.id) || !cli.command || cli.command !== tui.command) continue;
+ if (!['endpoint', 'apiKey', 'envVars'].every(key =>
+ isDeepStrictEqual(cli[key] || (key === 'envVars' ? {} : ''), tui[key] || (key === 'envVars' ? {} : '')))) continue;
+ groups.push([cli, tui]);
+ paired.add(cli.id);
+ paired.add(tui.id);
+ }
+ return [...groups, ...providers.filter(provider => !paired.has(provider.id)).map(provider => [provider])];
+}
+
+export function sharedModeUpdates(updates, sibling) {
+ // Arguments, timeouts, routing consent and model pins remain mode-specific.
+ const shared = Object.fromEntries(['enabled', 'models', 'modelContextWindows'].filter(key => Object.hasOwn(updates, key)).map(key => [key, updates[key]]));
+ // A caller deliberately repicking a default with a new catalog (the editor
+ // or harness discovery) must repair a removed sibling default too. Ordinary
+ // catalog probes omit defaultModel and retain their existing pin semantics.
+ if (Array.isArray(updates.models) && Object.hasOwn(updates, 'defaultModel') && sibling?.defaultModel && !updates.models.includes(sibling.defaultModel)) {
+ shared.defaultModel = updates.models[0] ?? null;
+ }
+ return shared;
+}
+
+export function unifyProviderModes(data) {
+ let changed = false;
+ for (const group of providerModeGroups(Object.values(data.providers || {}))) {
+ if (group.length < 2) continue;
+ const enabled = group.some(provider => provider.enabled === true);
+ const models = [...new Set(group.flatMap(provider => provider.models || []))];
+ for (const provider of group) {
+ if (provider.enabled !== enabled || !isDeepStrictEqual(provider.models, models)) {
+ Object.assign(provider, { enabled, models: [...models] });
+ changed = true;
+ }
+ }
+ }
+ return changed;
+}
diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js
index 214b40fc7a..ebf3136215 100644
--- a/server/lib/aiToolkit/providers.js
+++ b/server/lib/aiToolkit/providers.js
@@ -1,3 +1,4 @@
+import { providerModeGroups, sharedModeUpdates, unifyProviderModes } from './internal/providerModes.js';
import { readFile, rename } from 'fs/promises';
import { existsSync } from 'fs';
import { join, dirname, delimiter, isAbsolute } from 'path';
@@ -533,7 +534,8 @@ export function createProviderService(config = {}) {
console.error(`❌ sample providers file ${sampleFile} parse failed (${err.message}); starting from empty`);
return { activeProvider: null, providers: {} };
}
- await atomicWrite(PROVIDERS_PATH, sample);
+ unifyProviderModes(parsed);
+ await atomicWrite(PROVIDERS_PATH, parsed);
return parsed;
}
return { activeProvider: null, providers: {} };
@@ -546,7 +548,8 @@ export function createProviderService(config = {}) {
const migratedAntigravity = migrateAntigravityProviders(data);
const migratedAntigravityModels = migrateAntigravityModelCatalog(data);
const migratedContextWindows = migrateProviderContextWindows(data);
- if (migratedCodex || migratedAntigravity || migratedAntigravityModels || migratedContextWindows) {
+ const migratedModes = unifyProviderModes(data);
+ if (migratedModes || migratedCodex || migratedAntigravity || migratedAntigravityModels || migratedContextWindows) {
await atomicWrite(PROVIDERS_PATH, data);
if (migratedCodex) console.log('🔧 Migrated Codex providers to the selectable model catalog');
if (migratedAntigravity) console.log('🔧 Migrated Gemini provider config to Antigravity CLI (agy)');
@@ -711,6 +714,7 @@ export function createProviderService(config = {}) {
};
data.providers[id] = provider;
+ unifyProviderModes(data);
if (!data.activeProvider) {
data.activeProvider = id;
@@ -733,7 +737,11 @@ export function createProviderService(config = {}) {
id
};
+ const group = providerModeGroups(Object.values(data.providers)).find(modes => modes.some(mode => mode.id === id));
data.providers[id] = provider;
+ for (const sibling of group || []) {
+ if (sibling.id !== id) Object.assign(sibling, sharedModeUpdates(updates, sibling));
+ }
await saveProviders(data);
return provider;
},
@@ -745,9 +753,11 @@ export function createProviderService(config = {}) {
return false;
}
- delete data.providers[id];
+ const group = providerModeGroups(Object.values(data.providers)).find(modes => modes.some(mode => mode.id === id));
+ const removed = (group || []).map(mode => mode.id);
+ for (const modeId of removed) delete data.providers[modeId];
- if (data.activeProvider === id) {
+ if (removed.includes(data.activeProvider)) {
const remaining = Object.keys(data.providers);
data.activeProvider = remaining.length > 0 ? remaining[0] : null;
}
@@ -1119,11 +1129,13 @@ export function createProviderService(config = {}) {
// Built per member rather than once per group: `modelCatalogUpdate`
// merges against THAT provider's previously-learned windows, and it
// copies the list, so members never share a mutable instance.
- fresh.providers[id] = {
- ...provider,
- ...modelCatalogUpdate(group.catalog, provider.modelContextWindows),
- id,
- };
+ const modes = providerModeGroups(Object.values(fresh.providers)).find(entries => entries.some(entry => entry.id === id));
+ for (const mode of modes || [provider]) {
+ fresh.providers[mode.id] = {
+ ...mode,
+ ...modelCatalogUpdate(group.catalog, mode.modelContextWindows),
+ };
+ }
changed = true;
}
}
diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js
index 4338df8e3f..d100f8d3b9 100644
--- a/server/lib/aiToolkit/providers.test.js
+++ b/server/lib/aiToolkit/providers.test.js
@@ -23,6 +23,29 @@ describe('Provider Service', () => {
if (TEST_DATA_DIR) await rm(TEST_DATA_DIR, { recursive: true, force: true });
});
+ it('shares mode enablement and models while preserving mode-specific arguments, defaults and IDs', async () => {
+ await writeFile(join(TEST_DATA_DIR, 'providers.json'), JSON.stringify({ activeProvider: 'example-tui', providers: {
+ example: { id: 'example', name: 'Example CLI', type: 'cli', command: 'example', enabled: false, models: ['a'], args: ['--print'], defaultModel: 'a' },
+ 'example-tui': { id: 'example-tui', name: 'Example TUI', type: 'tui', command: 'example', enabled: true, models: ['b'], args: [], defaultModel: 'b' },
+ remote: { id: 'remote', type: 'api', enabled: false, models: ['remote'] },
+ } }));
+ expect((await providerService.getProviderById('example')).enabled).toBe(true);
+ expect((await providerService.getActiveProvider()).id).toBe('example-tui');
+ await providerService.updateProvider('example-tui', { enabled: false, models: ['c'], args: ['--interactive'], defaultModel: 'c' });
+ expect(await providerService.getProviderById('example')).toMatchObject({ enabled: false, models: ['c'], args: ['--print'], defaultModel: 'c' });
+ expect(await providerService.getProviderById('remote')).toMatchObject({ enabled: false, models: ['remote'] });
+ const catalog = vi.spyOn(providerService, 'fetchProviderModelCatalog').mockResolvedValue({ models: ['fresh'], contextWindows: { fresh: 8192 } });
+ await providerService.refreshProviderModelsBatch(['example-tui']);
+ expect((await providerService.getProviderById('example')).models).toEqual(['fresh']);
+ expect((await providerService.getProviderById('example-tui')).models).toEqual(['fresh']);
+ catalog.mockRestore();
+ await providerService.setActiveProvider('example');
+ expect((await providerService.getActiveProvider()).type).toBe('cli');
+ await providerService.deleteProvider('example-tui');
+ expect(await providerService.getProviderById('example')).toBeNull();
+ expect((await providerService.getActiveProvider()).id).toBe('remote');
+ });
+
it.skipIf(process.platform === 'win32')('refreshes Pi models and distinguishes authentication from probe failure', async () => {
const command = join(TEST_DATA_DIR, 'pi');
const emit = async (text, code = 0) => {
diff --git a/server/routes/providers.js b/server/routes/providers.js
index 9e3a4d2009..9a397e9226 100644
--- a/server/routes/providers.js
+++ b/server/routes/providers.js
@@ -1,3 +1,4 @@
+import { providerModeGroups } from '../lib/aiToolkit/internal/providerModes.js';
import { Router } from 'express';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import { testVision, runVisionTestSuite, checkVisionHealth } from '../services/visionTest.js';
@@ -219,6 +220,8 @@ export function createPortOSProviderRoutes(aiToolkit) {
router.get('/', asyncHandler(async (req, res) => {
const data = await providerService.getAllProviders();
const prerequisites = getProviderPrerequisiteMap(data.providers);
+ const modeGroups = new Map(providerModeGroups(data.providers).flatMap(group =>
+ group.map(provider => [provider.id, group.map(({ id, type }) => ({ id, type }))])));
const capabilities = await detectSystemCapabilities();
// Cache-only: this list must stay a synchronous read that spawns nothing.
// `null` here means NOT PROBED, and the dedicated `/codex/account` fetch is
@@ -233,6 +236,7 @@ export function createPortOSProviderRoutes(aiToolkit) {
activeProvider: data.activeProvider,
providers: data.providers.map((provider) => ({
...presentProvider(provider, capabilities),
+ executionModes: modeGroups.get(provider.id),
prerequisitesMet: prerequisites[provider.id]?.met ?? true,
missingPrerequisites: prerequisites[provider.id]?.missing ?? [],
// NON-blocking notices — today only 'this install's own ~/.codex/config.toml
diff --git a/server/routes/providers.prerequisites.test.js b/server/routes/providers.prerequisites.test.js
index 1dbb2ad7b4..b0a22333a2 100644
--- a/server/routes/providers.prerequisites.test.js
+++ b/server/routes/providers.prerequisites.test.js
@@ -42,6 +42,18 @@ const providersById = (res) => Object.fromEntries(res.body.providers.map((p) =>
beforeEach(() => vi.clearAllMocks());
describe('#4611: GET /api/providers publishes each provider\'s prerequisites', () => {
+ it('retains separate selectable execution records while describing one card per matching connection', async () => {
+ const tui = { ...CODEX, id: 'codex-tui', name: 'Codex TUI', type: 'tui' };
+ const response = await request(appWith([CODEX, tui, LOCAL_API])).get('/api/providers');
+ const byId = providersById(response);
+ expect(response.body.providers).toHaveLength(3);
+ expect(byId.codex.executionModes).toEqual([{ id: 'codex', type: 'cli' }, { id: 'codex-tui', type: 'tui' }]);
+ expect(byId['codex-tui'].executionModes).toEqual(byId.codex.executionModes);
+ expect(byId.lmstudio.executionModes).toEqual([{ id: 'lmstudio', type: 'api' }]);
+ const separate = providersById(await request(appWith([CODEX, { ...tui, envVars: { EXAMPLE_BACKEND: 'remote' } }])).get('/api/providers'));
+ expect(separate.codex.executionModes).toHaveLength(1);
+ });
+
it('flags a CLI provider whose binary is absent, and names it', async () => {
const byId = providersById(await request(appWith([CODEX, CLAUDE])).get('/api/providers'));