diff --git a/client/src/components/providers/FleetHostSetup.jsx b/client/src/components/providers/FleetHostSetup.jsx
index 1cfded5247..be3ad04889 100644
--- a/client/src/components/providers/FleetHostSetup.jsx
+++ b/client/src/components/providers/FleetHostSetup.jsx
@@ -7,6 +7,15 @@ import { copyToClipboard } from '../../lib/clipboard';
import { useAutoRefetch } from '../../hooks/useAutoRefetch';
import RuntimeInstallModal from '../install/RuntimeInstallModal';
import Banner from '../ui/Banner';
+import { PORTS } from '../../lib/ports.js';
+
+// A self-host provider (the auto-created Direct API one from `configure()` in
+// server/services/fleetLlmHost.js, and the OpenCode one from `?selfHost=1`)
+// is always wired to the loopback queue address, never `status.endpoint` (the
+// tailnet-facing address published for OTHER machines to connect to) — dedupe
+// against the address these providers actually use, or the "add a provider"
+// nudge below never clears even after the provider exists.
+const SELF_HOST_ENDPOINT = `http://127.0.0.1:${PORTS.FLEET_LLM}/v1`;
export default function FleetHostSetup({ compact = false, providers = [], onConfigured }) {
const [status, setStatus] = useState(null);
@@ -34,6 +43,18 @@ export default function FleetHostSetup({ compact = false, providers = [], onConf
return peerHosts.filter((host) => (host?.serving || host?.enabled) && !isFleetHostConfigured(host, providers));
}, [compact, peerHosts, providers]);
+ // Host setup only ever creates a Direct API provider on the host itself
+ // (fleetLlmHost.js `configure()`), never an OpenCode TUI one — and the
+ // peer-discovery cards above only ever surface OTHER instances, so a host
+ // machine that wants to also run OpenCode TUI against its own queue had no
+ // discoverable path to it. Reuse the same dedupe the peer cards use, keyed
+ // on the loopback address self-host providers are actually wired to.
+ const selfNeedsProvider = useMemo(
+ () => compact && Boolean(status?.serving || status?.enabled)
+ && !isFleetHostConfigured({ endpoint: SELF_HOST_ENDPOINT }, providers),
+ [compact, status, providers],
+ );
+
const reveal = () => {
setRevealing(true);
revealFleetLlmHostKey({ silent: true }).then(({ apiKey }) => setKey(apiKey))
@@ -43,39 +64,29 @@ export default function FleetHostSetup({ compact = false, providers = [], onConf
const actionClass = 'inline-flex items-center justify-center min-h-[40px] px-3 py-2 rounded-lg bg-port-accent text-white text-sm disabled:opacity-50';
const title = status?.recommendation.title || 'Recommended model host setup';
if (compact) {
- if (unconfiguredPeerHosts.length > 0) {
+ if (selfNeedsProvider || unconfiguredPeerHosts.length > 0) {
return (
+ {selfNeedsProvider && (
+
+ )}
{unconfiguredPeerHosts.map((host) => (
-
-
-
-
-
-
- Available federated host: {host.peerName}
-
-
- {host.serving ? 'Serving' : 'Enabled'} · {host.model || 'Qwen3.8-27B'}
-
-
-
- Instance {host.peerName} is running a federated LLM host. Set it up as a provider on this machine?
-
-
-
-
- Set up as provider
-
-
-
+ ariaLabel={`Available model host ${host.peerName}`}
+ headline={<>Available federated host:
{host.peerName} >}
+ badge={`${host.serving ? 'Serving' : 'Enabled'} · ${host.model || 'Qwen3.8-27B'}`}
+ description={<>Instance
{host.peerName} is running a federated LLM host. Set it up as a provider on this machine?>}
+ href={`/ai/fleet?fleetStep=client&peerId=${encodeURIComponent(host.peerId)}`}
+ actionClass={actionClass}
+ />
))}
);
@@ -112,7 +123,7 @@ export default function FleetHostSetup({ compact = false, providers = [], onConf
{status.recommendation.supported && (
- Reserve this GPU for Qwen. Setup reuses prepared weights, fills in missing runtime settings, keeps the container loaded, and creates a local API provider. A new install can download about 30 GB.
+ Reserve this GPU for Qwen. Setup reuses prepared weights, fills in missing runtime settings, keeps the container loaded, and creates a Direct API provider on this machine (not an OpenCode TUI one — add that separately below if you want coding agents on this same machine). A new install can download about 30 GB.
One active generation across all clients; up to 16 requests wait for at most two minutes. Disconnecting cancels the request. Requests are held in memory and are not replayed after a restart. Other GPU models must be unloaded first. Setup disables competing local providers so they do not reload automatically.
setInstalling(true)} className={actionClass}>
{status.serving ? 'Reapply recommended host setup' : 'Set up dedicated host · download if needed'}
@@ -126,6 +137,17 @@ export default function FleetHostSetup({ compact = false, providers = [], onConf
{status.queue.active} generating · {status.queue.queued} queued · limit {status.queue.maxActive} active / {status.queue.maxQueued} waiting
)}
+ {status.hasApiKey && status.endpoint && (
+
+ Use this host from this same machine
+
+ The Direct API provider above was created automatically. To also run OpenCode coding agents against this queue on this machine, add that provider explicitly — its endpoint and key are filled in for you.
+
+
+ Set up OpenCode TUI on this machine
+
+
+ )}
Connect another PortOS instance
@@ -154,3 +176,30 @@ export default function FleetHostSetup({ compact = false, providers = [], onConf
);
}
+
+// Shared shape for the compact "here's an unconfigured host, add it as a
+// provider?" prompt — used for both this machine's own host and a discovered
+// peer's, which differ only in copy and link target.
+function HostCard({ ariaLabel, headline, badge, description, href, actionClass }) {
+ return (
+
+
+
+
+
+
+ {headline}
+
+
{badge}
+
+
{description}
+
+
+ Set up as provider
+
+
+ );
+}
diff --git a/client/src/components/providers/FleetHostSetup.test.jsx b/client/src/components/providers/FleetHostSetup.test.jsx
index 64896e678c..7d11ad2c6c 100644
--- a/client/src/components/providers/FleetHostSetup.test.jsx
+++ b/client/src/components/providers/FleetHostSetup.test.jsx
@@ -82,5 +82,35 @@ describe('dedicated model host setup', () => {
expect(screen.queryByText(/Available federated host:/)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Set up as provider' })).not.toBeInTheDocument();
});
+
+ it('prompts to set up this machine itself when its own host is serving and unconfigured', async () => {
+ api.getFleetLlmHost.mockResolvedValue({ ...state, serving: true });
+ api.getFleetPeerHosts.mockResolvedValue({ hosts: [] });
+ render( );
+ expect(await screen.findByText(/serving its own model host/)).toBeInTheDocument();
+ const setupLink = screen.getByRole('link', { name: 'Set up as provider' });
+ expect(setupLink).toHaveAttribute('href', '/ai/fleet?fleetStep=client&selfHost=1');
+ });
+
+ it('does not prompt for this machine when it already has a matching provider', async () => {
+ api.getFleetLlmHost.mockResolvedValue({ ...state, serving: true });
+ api.getFleetPeerHosts.mockResolvedValue({ hosts: [] });
+ // Self-host providers are wired to the loopback queue address (both the
+ // auto-created Direct API one and the `?selfHost=1` OpenCode one) — never
+ // to `state.endpoint`, which is the tailnet address published for OTHER
+ // machines to connect to.
+ const providers = [{ id: 'self-p1', endpoint: 'http://127.0.0.1:18022/v1' }];
+ render( );
+ expect(await screen.findByText('Recommended model host setup')).toBeInTheDocument();
+ expect(screen.queryByText(/serving its own model host/)).not.toBeInTheDocument();
+ });
+
+ it('offers a one-click self-host OpenCode TUI setup on the full host panel', async () => {
+ api.getFleetLlmHost.mockResolvedValue(state);
+ api.getFleetPeerHosts.mockResolvedValue({ hosts: [] });
+ render( );
+ const link = await screen.findByRole('link', { name: 'Set up OpenCode TUI on this machine' });
+ expect(link).toHaveAttribute('href', '/ai/fleet?fleetStep=client&selfHost=1');
+ });
});
diff --git a/client/src/components/providers/FleetProviderSetup.jsx b/client/src/components/providers/FleetProviderSetup.jsx
index fa77cac120..baae56af3e 100644
--- a/client/src/components/providers/FleetProviderSetup.jsx
+++ b/client/src/components/providers/FleetProviderSetup.jsx
@@ -6,8 +6,8 @@ import FleetHostSetup from './FleetHostSetup';
import useDrawerTab from '../../hooks/useDrawerTab';
import { FormField } from '../ui/FormField';
import Banner from '../ui/Banner';
-import { isLocalEndpoint, isPrivateNetworkEndpoint } from '../../utils/providers';
-import { revealFleetPeerHostKey } from '../../services/apiProviders';
+import { commandBasename, isApiProvider, isLocalEndpoint, isPrivateNetworkEndpoint, isTuiProvider, mergeProviderUpdate } from '../../utils/providers';
+import { getFleetLlmHost, revealFleetLlmHostKey, revealFleetPeerHostKey } from '../../services/apiProviders';
import { PORTS } from '../../lib/ports.js';
const FLEET_TABS = [
@@ -82,11 +82,17 @@ export const buildFleetProvider = ({ name, endpoint, apiKey, model, harness }) =
};
};
-export default function FleetProviderSetup({ peers = [], onClose, onCreate, onConfigured }) {
+export default function FleetProviderSetup({ peers = [], providers = [], onClose, onCreate, onUpdate, onConfigured }) {
const [searchParams] = useSearchParams();
const initialPeerId = searchParams.get('peerId') || '';
+ // `?selfHost=1` is how the host's own status card (FleetHostSetup) links
+ // here: this machine already runs the queue, so skip peer discovery and
+ // prefill the loopback address host setup already wired the auto-created
+ // Direct API provider to, plus this machine's own key.
+ const selfHost = searchParams.get('selfHost') === '1';
const [activeTab, setActiveTab] = useDrawerTab('fleetStep', 'architecture', FLEET_TAB_IDS);
const [selectedPeerId, setSelectedPeerId] = useState(initialPeerId);
+ const [targetProviderId, setTargetProviderId] = useState('');
const [endpointInput, setEndpointInput] = useState('');
const [name, setName] = useState('Fleet GPU · OpenCode TUI');
const [apiKey, setApiKey] = useState('');
@@ -94,17 +100,59 @@ export default function FleetProviderSetup({ peers = [], onClose, onCreate, onCo
const [harness, setHarness] = useState('tui');
const [saving, setSaving] = useState(false);
const [fetchingKey, setFetchingKey] = useState(false);
+ const [selfHostLoading, setSelfHostLoading] = useState(false);
const [error, setError] = useState('');
const availablePeers = useMemo(
() => peers.filter((peer) => peer?.enabled !== false && (peer?.host || peer?.address)),
[peers],
);
+ // Repoint targets: providers a fleet endpoint can plausibly replace — an
+ // existing OpenCode TUI or Direct API provider. Without this, the only way
+ // to point an already-created provider at a fleet host was delete-and-recreate.
+ // A TUI provider must already be OpenCode (or have no command set yet) —
+ // `buildFleetProvider` always overwrites `command`/`args`/`envVars` with the
+ // OpenCode wiring, so repointing a Claude/Codex/Grok TUI provider here would
+ // silently convert it into an OpenCode one out from under the user.
+ const repointCandidates = useMemo(
+ () => providers.filter((provider) => (
+ isApiProvider(provider)
+ || (isTuiProvider(provider) && (!provider.command || commandBasename(provider.command) === 'opencode'))
+ )),
+ [providers],
+ );
+ const repointTarget = useMemo(
+ () => providers.find((provider) => provider.id === targetProviderId) || null,
+ [providers, targetProviderId],
+ );
const endpoint = normalizeEndpoint(endpointInput);
+ const fetchKeyFor = async (peerId) => {
+ if (!peerId) return;
+ setFetchingKey(true);
+ setError('');
+ try {
+ const res = await revealFleetPeerHostKey(peerId, { silent: true });
+ if (res?.apiKey) {
+ setApiKey(res.apiKey);
+ } else {
+ setError('Host did not return an API key. Enter it manually.');
+ }
+ } catch (err) {
+ setError(err?.message || 'Could not retrieve API key from host. Enter it manually.');
+ } finally {
+ setFetchingKey(false);
+ }
+ };
+
+ // Selecting a known peer auto-fetches its key too — a user who only fills the
+ // pre-populated fields and clicks Create must not be stopped by a manual
+ // "Fetch API key" click they had no reason to expect: submit rejects a blank
+ // key, but nothing upstream of that prompts for it.
const selectPeer = (peerId) => {
setSelectedPeerId(peerId);
const peer = availablePeers.find(({ id }) => id === peerId);
setEndpointInput(peer ? endpointForPeer(peer) : '');
+ if (peerId) fetchKeyFor(peerId);
};
useEffect(() => {
@@ -113,27 +161,57 @@ export default function FleetProviderSetup({ peers = [], onClose, onCreate, onCo
}
}, [initialPeerId, availablePeers]);
- const handleFetchKey = async () => {
- if (!selectedPeerId) return;
- setFetchingKey(true);
+ // Self-host: this machine already runs the queue on the loopback address
+ // host setup wired the auto-created Direct API provider to (see
+ // `configure()` in server/services/fleetLlmHost.js), so there is no peer to
+ // pick — fetch this machine's own key instead of asking for one by hand.
+ useEffect(() => {
+ if (!selfHost || endpointInput) return;
+ let cancelled = false;
+ setSelfHostLoading(true);
setError('');
- try {
- const res = await revealFleetPeerHostKey(selectedPeerId, { silent: true });
- if (res?.apiKey) {
- setApiKey(res.apiKey);
- } else {
- setError('Host did not return an API key. Enter it manually.');
+ Promise.all([
+ getFleetLlmHost({ silent: true }).catch(() => null),
+ revealFleetLlmHostKey({ silent: true }).catch(() => null),
+ ]).then(([status, keyRes]) => {
+ if (cancelled) return;
+ if (!status?.hasApiKey) {
+ setError('Complete Model host setup (the GPU host tab) on this machine first, then come back to connect it.');
+ return;
}
- } catch (err) {
- setError(err?.message || 'Could not retrieve API key from host.');
- } finally {
- setFetchingKey(false);
- }
- };
+ setEndpointInput(`http://127.0.0.1:${DEFAULT_PORT}/v1`);
+ if (status.model) setModel(status.model);
+ if (keyRes?.apiKey) setApiKey(keyRes.apiKey);
+ else setError('Could not read this host\'s API key. Enter it manually from the GPU host tab.');
+ }).finally(() => { if (!cancelled) setSelfHostLoading(false); });
+ return () => { cancelled = true; };
+ }, [selfHost, endpointInput]);
+
+ const handleFetchKey = () => fetchKeyFor(selectedPeerId);
const selectHarness = (next) => {
setHarness(next);
- setName(next === 'tui' ? 'Fleet GPU · OpenCode TUI' : 'Fleet GPU · API');
+ if (!targetProviderId) setName(next === 'tui' ? 'Fleet GPU · OpenCode TUI' : 'Fleet GPU · API');
+ };
+
+ // Repointing an existing provider locks the harness to its current type —
+ // an in-place update changes its endpoint/key/model, not what kind of
+ // provider it is.
+ const selectTarget = (id) => {
+ setTargetProviderId(id);
+ const target = providers.find((provider) => provider.id === id);
+ if (!target) {
+ // Back to "create a new provider" — undo whatever a previously
+ // selected target's type/name/model left behind, or the form keeps
+ // showing that provider's values with no visible reason why.
+ setHarness('tui');
+ setName('Fleet GPU · OpenCode TUI');
+ setModel(DEFAULT_MODEL);
+ return;
+ }
+ setHarness(isTuiProvider(target) ? 'tui' : 'api');
+ setName(target.name || name);
+ if (target.defaultModel) setModel(target.defaultModel);
};
const submit = (event) => {
@@ -141,16 +219,25 @@ export default function FleetProviderSetup({ peers = [], onClose, onCreate, onCo
setError('');
if (!name.trim()) return setError('Provider name is required.');
if (!URL.canParse(endpoint)) return setError('Enter a full HTTP endpoint for the GPU host.');
- if (isLocalEndpoint(endpoint) || !isPrivateNetworkEndpoint(endpoint)) {
+ // Self-host mode prefills the loopback address on purpose (it's this same
+ // machine) — but if the user then edits that field to something else, the
+ // normal "must be a private/remote endpoint" rule still applies rather
+ // than skipping validation for whatever they typed.
+ if (!(selfHost && isLocalEndpoint(endpoint)) && (isLocalEndpoint(endpoint) || !isPrivateNetworkEndpoint(endpoint))) {
return setError('Use a private LAN, MagicDNS, or Tailscale endpoint on another machine.');
}
if (!apiKey.trim()) return setError('The networked vLLM runtime must have an API key.');
if (!model.trim()) return setError('Model id is required.');
setSaving(true);
- return onCreate(buildFleetProvider({ name, endpoint, apiKey, model, harness }))
+ // A partial update replaces whichever fields it names — merge rather than
+ // overwrite so an existing provider's unrelated env vars, models, and
+ // secret markers survive being repointed at a new fleet host.
+ const payload = mergeProviderUpdate(repointTarget, buildFleetProvider({ name, endpoint, apiKey, model, harness }));
+ const save = repointTarget ? onUpdate(repointTarget.id, payload) : onCreate(payload);
+ return save
.then(onClose)
- .catch((err) => setError(err?.message || 'Could not create the fleet provider.'))
+ .catch((err) => setError(err?.message || 'Could not save the fleet provider.'))
.finally(() => setSaving(false));
};
@@ -208,10 +295,31 @@ export default function FleetProviderSetup({ peers = [], onClose, onCreate, onCo
{activeTab === 'client' && (
diff --git a/client/src/components/providers/FleetProviderSetup.test.jsx b/client/src/components/providers/FleetProviderSetup.test.jsx
index 1db6c190de..c0750d9d9b 100644
--- a/client/src/components/providers/FleetProviderSetup.test.jsx
+++ b/client/src/components/providers/FleetProviderSetup.test.jsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import FleetProviderSetup from './FleetProviderSetup';
@@ -10,6 +10,11 @@ const api = vi.hoisted(() => ({
}));
vi.mock('../../services/apiProviders', () => api);
+const existingProviders = [
+ { id: 'opencode-1', name: 'My OpenCode', type: 'tui', command: 'opencode', envVars: { OTHER_VAR: '1' }, models: ['old-model'] },
+ { id: 'claude-tui-1', name: 'My Claude Code', type: 'tui', command: 'claude' },
+];
+
const peers = [
{ id: 'peer-1', name: 'Workstation GPU', host: 'workstation.tailnet.ts.net', enabled: true },
{ id: 'peer-2', name: 'MacBook', address: '192.168.1.50', enabled: true },
@@ -34,6 +39,42 @@ describe('FleetProviderSetup', () => {
expect(endpointInput.value).toBe('http://workstation.tailnet.ts.net:18022/v1');
});
+ it('auto-fetches the API key as soon as a peer is selected from the URL, with no extra click', async () => {
+ api.revealFleetPeerHostKey.mockResolvedValue({ apiKey: 'auto-fetched-key-123456789012' });
+
+ render(
+
+ {}} onCreate={vi.fn()} />
+
+ );
+
+ await waitFor(() => {
+ expect(api.revealFleetPeerHostKey).toHaveBeenCalledWith('peer-1', { silent: true });
+ });
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Enter host API key').value).toBe('auto-fetched-key-123456789012');
+ });
+ });
+
+ it('auto-fetches the API key when a peer is chosen from the dropdown, not just from the URL', async () => {
+ api.revealFleetPeerHostKey.mockResolvedValue({ apiKey: 'dropdown-fetched-key-12345678' });
+
+ render(
+
+ {}} onCreate={vi.fn()} />
+
+ );
+
+ fireEvent.change(screen.getByLabelText('Known PortOS peer'), { target: { value: 'peer-2' } });
+
+ await waitFor(() => {
+ expect(api.revealFleetPeerHostKey).toHaveBeenCalledWith('peer-2', { silent: true });
+ });
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Enter host API key').value).toBe('dropdown-fetched-key-12345678');
+ });
+ });
+
it('fetches API key from host when clicked', async () => {
api.revealFleetPeerHostKey.mockResolvedValue({ apiKey: 'host-secret-key-123456789012' });
@@ -83,4 +124,119 @@ describe('FleetProviderSetup', () => {
);
});
});
+
+ it('prefills this machine\'s own loopback endpoint and key in self-host mode', async () => {
+ api.getFleetLlmHost.mockResolvedValue({ hasApiKey: true, model: 'qwen3.8-27b' });
+ api.revealFleetLlmHostKey.mockResolvedValue({ apiKey: 'self-host-key-1234567890123456' });
+
+ render(
+
+ {}} onCreate={vi.fn()} />
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('GPU host endpoint').value).toBe('http://127.0.0.1:18022/v1');
+ });
+ expect(screen.getByPlaceholderText('Enter host API key').value).toBe('self-host-key-1234567890123456');
+ });
+
+ it('still validates the endpoint in self-host mode if the user edits it away from loopback', async () => {
+ api.getFleetLlmHost.mockResolvedValue({ hasApiKey: true, model: 'qwen3.8-27b' });
+ api.revealFleetLlmHostKey.mockResolvedValue({ apiKey: 'self-host-key-1234567890123456' });
+ const onCreate = vi.fn();
+
+ render(
+
+ {}} onCreate={onCreate} />
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('GPU host endpoint').value).toBe('http://127.0.0.1:18022/v1');
+ });
+ // The self-host bypass exists only for the prefilled loopback value — an
+ // edited-away public endpoint must still fail the private-network check.
+ fireEvent.change(screen.getByLabelText('GPU host endpoint'), { target: { value: 'http://example.com:18022/v1' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create fleet provider' }));
+
+ expect(await screen.findByText(/private LAN, MagicDNS, or Tailscale endpoint/)).toBeInTheDocument();
+ expect(onCreate).not.toHaveBeenCalled();
+ });
+
+ it('surfaces an error in self-host mode when the host has not been set up yet', async () => {
+ api.getFleetLlmHost.mockResolvedValue({ hasApiKey: false });
+
+ render(
+
+ {}} onCreate={vi.fn()} />
+
+ );
+
+ expect(await screen.findByText(/Complete Model host setup/)).toBeInTheDocument();
+ });
+
+ it('updates an existing provider in place instead of creating a new one when repointing', async () => {
+ const onUpdate = vi.fn().mockResolvedValue({});
+ const onCreate = vi.fn();
+ const onClose = vi.fn();
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(await screen.findByLabelText('Provider'), { target: { value: 'opencode-1' } });
+ fireEvent.change(screen.getByPlaceholderText('Enter host API key'), { target: { value: 'repoint-key-at-least-24-characters' } });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Update provider' }));
+
+ await waitFor(() => {
+ expect(onUpdate).toHaveBeenCalledWith(
+ 'opencode-1',
+ expect.objectContaining({
+ name: 'My OpenCode',
+ apiKey: 'repoint-key-at-least-24-characters',
+ type: 'tui',
+ // The provider's other env var and previously-served model survive
+ // the repoint instead of being clobbered by the fleet defaults.
+ envVars: expect.objectContaining({ OTHER_VAR: '1' }),
+ models: expect.arrayContaining(['old-model', 'qwen3.8-27b']),
+ })
+ );
+ });
+ expect(onCreate).not.toHaveBeenCalled();
+ });
+
+ it('does not offer a non-OpenCode TUI provider as a repoint target', async () => {
+ render(
+
+ {}} onCreate={vi.fn()} onUpdate={vi.fn()} />
+
+ );
+
+ const providerSelect = await screen.findByLabelText('Provider');
+ // buildFleetProvider always overwrites command/args/envVars with the
+ // OpenCode wiring — repointing a Claude Code TUI provider here would
+ // silently convert it into an OpenCode one out from under the user.
+ expect(within(providerSelect).queryByText(/My Claude Code/)).not.toBeInTheDocument();
+ expect(within(providerSelect).getByText(/My OpenCode/)).toBeInTheDocument();
+ });
+
+ it('resets name/model/harness back to defaults when switching from a selected target back to "create a new provider"', async () => {
+ render(
+
+ {}} onCreate={vi.fn()} onUpdate={vi.fn()} />
+
+ );
+
+ const providerSelect = await screen.findByLabelText('Provider');
+ fireEvent.change(providerSelect, { target: { value: 'opencode-1' } });
+ expect(screen.getByDisplayValue('My OpenCode')).toBeInTheDocument();
+
+ fireEvent.change(providerSelect, { target: { value: '' } });
+ expect(screen.getByDisplayValue('Fleet GPU · OpenCode TUI')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('qwen3.8-27b')).toBeInTheDocument();
+ });
});
diff --git a/client/src/pages/AIProviders.jsx b/client/src/pages/AIProviders.jsx
index 05b3bb46ee..c2c29e7826 100644
--- a/client/src/pages/AIProviders.jsx
+++ b/client/src/pages/AIProviders.jsx
@@ -527,6 +527,15 @@ export default function AIProviders() {
return created;
};
+ // Repoints an existing provider at a fleet host in place, rather than
+ // leaving the user to create a duplicate and manually delete the old one.
+ const handleUpdateFleetProvider = async (id, patch) => {
+ const updated = await api.updateProvider(id, patch);
+ setProviders((current) => current.map((entry) => (entry.id === id ? updated : entry)));
+ toast.success(`${updated.name} is connected to the fleet GPU host`);
+ return updated;
+ };
+
const handleAddAllSamples = async () => {
if (addableSamples.length === 0) return;
@@ -1066,8 +1075,10 @@ export default function AIProviders() {
{fleetSetupOpen && (
)}
diff --git a/client/src/utils/providers.js b/client/src/utils/providers.js
index 9725a743d3..3bb3e5e29a 100644
--- a/client/src/utils/providers.js
+++ b/client/src/utils/providers.js
@@ -1293,6 +1293,28 @@ export const mergeModelLists = (...lists) => {
return out;
};
+/**
+ * Merge a partial-update payload onto an existing provider record in place, so
+ * repointing a provider at a new backend (e.g. a fleet host) doesn't clobber
+ * fields the payload didn't set out to change. A raw PATCH replaces whichever
+ * top-level keys it names wholesale — without this, pointing an OpenCode TUI
+ * provider at a new endpoint would silently drop its other env vars and reset
+ * its served-model history to just the one new model id.
+ *
+ * @param {object|null|undefined} target - the existing provider being updated
+ * @param {object} payload - field values about to be written; mutated in place
+ * @returns {object} payload
+ */
+export const mergeProviderUpdate = (target, payload) => {
+ if (!target) return payload;
+ if (payload.envVars) payload.envVars = { ...target.envVars, ...payload.envVars };
+ if (payload.secretEnvVars) {
+ payload.secretEnvVars = Array.from(new Set([...(target.secretEnvVars || []), ...payload.secretEnvVars]));
+ }
+ if (payload.models) payload.models = mergeModelLists(target.models, payload.models);
+ return payload;
+};
+
/**
* Display label for a model ``: the id plus a "(32K ctx)" parenthetical
* when the model's context window is known. The option's `value` stays the raw
diff --git a/docs/features/fleet-llm-host.md b/docs/features/fleet-llm-host.md
index 4da6a261fb..2ad24c28cc 100644
--- a/docs/features/fleet-llm-host.md
+++ b/docs/features/fleet-llm-host.md
@@ -55,7 +55,9 @@ Other existing PortOS paths remain useful, but solve different problems:
Open **AI Providers → Model host setup → Host**. The banner is visible above the provider list and detects GPU/platform capabilities. On Windows/Linux with an RTX 3090 (24 GB), the recommended action prepares missing weights, preserves the prepared image by digest, selects DFlash 2 and prefix caching, and starts a persistent container. The recorded warm agent-prompt result was **105.3 tok/s**; this is a measured reference, not a speed guarantee for every prompt.
-Setup binds the underlying runtime to loopback `:18020` and exposes a bearer-authenticated queue on `:18022`. It creates a direct API provider and moves existing local vLLM providers (including OpenCode's baseURL) through the queue. Competing local LM Studio/Ollama/llama/SGLang/SlotStream providers are disabled; their configurations remain available. Unload other GPU models before starting. PortOS no longer enables the default local backend at boot while dedicated hosting is enabled.
+Setup binds the underlying runtime to loopback `:18020` and exposes a bearer-authenticated queue on `:18022`. It creates a **Direct API** provider — not an OpenCode TUI one — and moves existing local vLLM providers (including OpenCode's baseURL) through the queue. Competing local LM Studio/Ollama/llama/SGLang/SlotStream providers are disabled; their configurations remain available. Unload other GPU models before starting. PortOS no longer enables the default local backend at boot while dedicated hosting is enabled.
+
+Once the host is configured (`hasApiKey` and an endpoint are both present), the host panel itself offers **Set up OpenCode TUI on this machine** — the same "Connect client" walkthrough below, but pre-filled with this machine's own loopback endpoint and key, so running coding agents on the host machine doesn't require hand-copying its own credentials. The provider list on this page also surfaces this as an "add a provider" prompt when the host is serving and no matching provider exists yet.
The status panel checks Docker, prepared weights/key, the loaded model, Tailscale and queue listener. Copy the endpoint and explicitly reveal/copy the key only when connecting a client. The key never appears in the status payload. Driver installation, Docker engine repair, Windows reboot and enabling Docker Desktop startup may still require host interaction. Setup enables the model distro in Docker Desktop, wakes it before retrying a failed WSL integration, and registers a Windows login task to restore PortOS and the prepared container. The login task waits for Docker, never downloads weights or generates tokens, and does nothing when the dedicated-host flag is disabled. Once Docker starts, the container's `unless-stopped` policy restores the model; PortOS restores the queue from its machine-local opt-in flag. Cold model initialization can take 5–7 minutes. Boot makes no generation requests.
@@ -77,8 +79,13 @@ On each client PortOS:
`VLLM_API_KEY`, and keep the served model id in sync.
4. Choose **OpenCode TUI** for CoS coding agents. Choose **Direct API** for
PortOS text-generation calls that do not need a file/tool harness.
-5. Create the provider, refresh models, run the card test, then test one small
- tool-using workspace before making it the default.
+5. Optionally point an existing provider at this host instead of creating a
+ new one: pick it from the **Provider** dropdown (candidates are existing
+ OpenCode TUI and Direct API providers). Its harness type is locked, but its
+ endpoint, key, and model are updated in place — its other env vars and
+ previously-served models are preserved rather than overwritten.
+6. Create (or update) the provider, refresh models, run the card test, then
+ test one small tool-using workspace before making it the default.
OpenCode is the optimal coding harness for this vLLM server because it speaks
the OpenAI-compatible protocol directly and preserves structured tool calls.