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
15 changes: 15 additions & 0 deletions client/src/components/ProviderModelSelector.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
50 changes: 41 additions & 9 deletions client/src/components/providers/ProviderCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export default function ProviderCard({
status,
isDefault,
providersById,
activeProviderId,
statuses = {},
runnerAllowedCommands,
testResult,
refreshing,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -153,13 +158,13 @@ export default function ProviderCard({
to split, and it is narrower than the viewport by the sidebar. */}
<div className="flex flex-col @2xl:flex-row @2xl:items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="text-lg font-semibold text-white">{provider.name}</h3>
<h3 className="text-lg font-semibold text-white">{unified ? provider.name.replace(/\b(CLI|TUI)\b\s*/i, '').trim() : provider.name}</h3>
<span className={`text-xs px-2 py-0.5 rounded ${providerTypeClass(provider.type)}`}>
{provider.type.toUpperCase()}
{unified ? 'CLI / TUI' : provider.type.toUpperCase()}
</span>
{isDefault && (
<span className="text-xs px-2 py-0.5 rounded bg-port-accent/20 text-port-accent">
DEFAULT
DEFAULT{unified ? ` · ${provider.type.toUpperCase()}` : ''}
</span>
)}
{fleetProvider && (
Expand Down Expand Up @@ -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) && (
<Link
to={`/shell?provider=${encodeURIComponent(provider.id)}`}
to={`/shell?provider=${encodeURIComponent(shellProvider.id)}`}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-port-accent/20 text-port-accent hover:bg-port-accent/30 rounded transition-colors"
title={`Launch in Shell: ${provider.tuiCommandLine}`}
title={`Launch TUI in Shell: ${shellProvider.tuiCommandLine}`}
>
<Terminal size={14} />
Launch in Shell
Expand Down Expand Up @@ -291,7 +296,7 @@ export default function ProviderCard({
{provider.enabled ? 'Disable' : 'Enable'}
</button>

{!isDefault && provider.enabled && (
{!unified && !isDefault && provider.enabled && (
<button
onClick={() => onSetActive(provider.id)}
disabled={!subscriptionReady}
Expand All @@ -301,12 +306,28 @@ export default function ProviderCard({
</button>
)}

<button
{unified && modes.map(mode => (
<span key={mode.id} className="inline-flex flex-wrap items-center gap-2">
{provider.enabled && (
<button
onClick={() => 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`}
</button>
)}
<button onClick={() => onEdit(mode)} className="px-3 py-1.5 text-sm bg-port-border text-white rounded">
Edit {mode.type.toUpperCase()}
</button>
</span>
))}
{!unified && <button
onClick={() => onEdit(provider)}
className="px-3 py-1.5 text-sm bg-port-border hover:bg-port-border/80 text-white rounded transition-colors"
>
Edit
</button>
</button>}

<button
onClick={() => onDelete(provider.id)}
Expand All @@ -320,6 +341,17 @@ export default function ProviderCard({
{/* Card body — full width, below the header row rather than beside the
action buttons. */}
<div className="mt-3 space-y-2">
{unified && (
<div className="text-xs text-gray-400 space-y-1">
<p>CLI and TUI share enablement and the model catalog. Edit a mode to configure its arguments and model defaults.</p>
{modes.filter(mode => mode.id !== provider.id && statuses[mode.id]?.available === false).map(mode => (
<p key={mode.id} className="text-port-warning">
{mode.type.toUpperCase()} benched: {statuses[mode.id].message || statuses[mode.id].reason}{' '}
<button onClick={() => onRecover(mode.id)} className="underline">Retry {mode.type.toUpperCase()}</button>
</p>
))}
</div>
)}
<CodexRoutingNotice
advisory={routingAdvisory}
className="max-w-3xl"
Expand Down
11 changes: 9 additions & 2 deletions client/src/pages/AIProviders.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -631,8 +631,13 @@ export default function AIProviders() {
};
// The hardware veto is decided first: what this machine cannot run never
// reaches the readiness buckets, so a card lands in exactly one section.
const runnable = providers.filter(isProviderHardwareCompatible);
const unrunnable = providers.filter(p => !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,
Expand Down Expand Up @@ -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])}
Expand Down
35 changes: 35 additions & 0 deletions client/src/pages/AIProviders.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down Expand Up @@ -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 }],
Expand Down
20 changes: 20 additions & 0 deletions scripts/migrations/355-unify-provider-modes.js
Original file line number Diff line number Diff line change
@@ -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 };
},
};
34 changes: 34 additions & 0 deletions scripts/migrations/355-unify-provider-modes.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
48 changes: 48 additions & 0 deletions server/lib/aiToolkit/internal/providerModes.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading