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.

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 `