From 6294ea97245f4311ebf7136296c7897ab38911d1 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 04:02:21 +0000 Subject: [PATCH 1/4] fix: auto-fetch the fleet host API key when a peer is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting a client to a known federated fleet host required an easy-to-miss manual "Fetch API key from host" click before Create would succeed — clicking Create with the field still blank just set a small error banner, which read as "nothing happened, no provider was created." Selecting a peer (from the URL or the dropdown) now fetches its key immediately, so the common path of prefilled-endpoint + Create works in one step. --- .../providers/FleetProviderSetup.jsx | 39 +++++++++++-------- .../providers/FleetProviderSetup.test.jsx | 36 +++++++++++++++++ 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/client/src/components/providers/FleetProviderSetup.jsx b/client/src/components/providers/FleetProviderSetup.jsx index fa77cac120..b2fb09297d 100644 --- a/client/src/components/providers/FleetProviderSetup.jsx +++ b/client/src/components/providers/FleetProviderSetup.jsx @@ -101,36 +101,43 @@ export default function FleetProviderSetup({ peers = [], onClose, onCreate, onCo ); const endpoint = normalizeEndpoint(endpointInput); - const selectPeer = (peerId) => { - setSelectedPeerId(peerId); - const peer = availablePeers.find(({ id }) => id === peerId); - setEndpointInput(peer ? endpointForPeer(peer) : ''); - }; - - useEffect(() => { - if (initialPeerId && availablePeers.length > 0 && !endpointInput) { - selectPeer(initialPeerId); - } - }, [initialPeerId, availablePeers]); - - const handleFetchKey = async () => { - if (!selectedPeerId) return; + const fetchKeyFor = async (peerId) => { + if (!peerId) return; setFetchingKey(true); setError(''); try { - const res = await revealFleetPeerHostKey(selectedPeerId, { silent: true }); + 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.'); + 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(() => { + if (initialPeerId && availablePeers.length > 0 && !endpointInput) { + selectPeer(initialPeerId); + } + }, [initialPeerId, availablePeers]); + + const handleFetchKey = () => fetchKeyFor(selectedPeerId); + const selectHarness = (next) => { setHarness(next); setName(next === 'tui' ? 'Fleet GPU · OpenCode TUI' : 'Fleet GPU · API'); diff --git a/client/src/components/providers/FleetProviderSetup.test.jsx b/client/src/components/providers/FleetProviderSetup.test.jsx index 1db6c190de..a3de83cf22 100644 --- a/client/src/components/providers/FleetProviderSetup.test.jsx +++ b/client/src/components/providers/FleetProviderSetup.test.jsx @@ -34,6 +34,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' }); From 66c9acea9ca7ccee46995516a39d89830bd0e446 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 04:13:44 +0000 Subject: [PATCH 2/4] fix confusing fleet-provider setup: self-host TUI path and repoint-in-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host setup silently only ever created a Direct API provider on the host itself (never an OpenCode TUI one), and the peer-discovery cards on the AI Providers page only ever surfaced OTHER PortOS instances — so a machine running its own fleet host had no discoverable way to add itself as a provider, and any existing provider could only be pointed at a fleet host by creating a duplicate and manually deleting the old one. Add a "Set up as provider" / "Set up OpenCode TUI on this machine" one-click path (?selfHost=1) that prefills the loopback endpoint and this machine's own API key, and let the Connect Client tab repoint an existing OpenCode TUI or Direct API provider in place instead of always creating a new one, merging rather than clobbering its other env vars, models, and secret markers. --- .../components/providers/FleetHostSetup.jsx | 100 ++++++++++----- .../providers/FleetHostSetup.test.jsx | 26 ++++ .../providers/FleetProviderSetup.jsx | 114 ++++++++++++++++-- .../providers/FleetProviderSetup.test.jsx | 65 ++++++++++ client/src/pages/AIProviders.jsx | 11 ++ client/src/utils/providers.js | 22 ++++ docs/features/fleet-llm-host.md | 13 +- 7 files changed, 305 insertions(+), 46 deletions(-) diff --git a/client/src/components/providers/FleetHostSetup.jsx b/client/src/components/providers/FleetHostSetup.jsx index 1cfded5247..77e71b3c11 100644 --- a/client/src/components/providers/FleetHostSetup.jsx +++ b/client/src/components/providers/FleetHostSetup.jsx @@ -34,6 +34,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 this machine's own tailnet endpoint. + const selfNeedsProvider = useMemo( + () => compact && Boolean(status?.endpoint) && Boolean(status?.serving || status?.enabled) + && !isFleetHostConfigured({ endpoint: status.endpoint }, providers), + [compact, status, providers], + ); + const reveal = () => { setRevealing(true); revealFleetLlmHostKey({ silent: true }).then(({ apiKey }) => setKey(apiKey)) @@ -43,39 +55,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 +114,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.

diff --git a/client/src/components/providers/FleetProviderSetup.test.jsx b/client/src/components/providers/FleetProviderSetup.test.jsx index a3de83cf22..7d247c11bc 100644 --- a/client/src/components/providers/FleetProviderSetup.test.jsx +++ b/client/src/components/providers/FleetProviderSetup.test.jsx @@ -10,6 +10,10 @@ 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'] }, +]; + 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 }, @@ -119,4 +123,65 @@ 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('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(); + }); }); 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 `