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 && ( )} - + )} + + + ))} + {!unified && + } +

+ ))} +
+ )} !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'));