diff --git a/client/src/App.jsx b/client/src/App.jsx index 75ce3a179a..7e5fd18ead 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -280,13 +280,15 @@ export default function App() { } /> } /> } /> - {/* The provider editor is a deep-linkable slide-in over the same page: - /ai/new creates, /ai/edit/:providerId edits. The id sits under its - own `edit` segment rather than directly under /ai so the create - route can't be shadowed by a real provider: ids are slugified from - the display name, so a provider named "New" gets the id `new` and - /ai/new would otherwise match the static create route instead. */} + {/* Provider overlays are deep-linkable over the same page: /ai/new + creates, /ai/fleet walks through a remote GPU host, and + /ai/edit/:providerId edits. The id sits under its own `edit` + segment rather than directly under /ai so the create route can't + be shadowed by a real provider: ids are slugified from the display + name, so a provider named "New" gets the id `new` and /ai/new + would otherwise match the static create route instead. */} } /> + } /> } /> } /> } /> diff --git a/client/src/components/providers/FleetProviderSetup.jsx b/client/src/components/providers/FleetProviderSetup.jsx new file mode 100644 index 0000000000..8afca9f19e --- /dev/null +++ b/client/src/components/providers/FleetProviderSetup.jsx @@ -0,0 +1,327 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { ExternalLink, Network, Server, WandSparkles } from 'lucide-react'; +import Drawer from '../Drawer'; +import useDrawerTab from '../../hooks/useDrawerTab'; +import { FormField } from '../ui/FormField'; +import Banner from '../ui/Banner'; +import { isLocalEndpoint, isPrivateNetworkEndpoint } from '../../utils/providers'; + +const FLEET_TABS = [ + { id: 'architecture', label: 'Architecture' }, + { id: 'host', label: 'GPU host' }, + { id: 'client', label: 'Connect client' }, + { id: 'verify', label: 'Verify' }, +]; +const FLEET_TAB_IDS = FLEET_TABS.map(({ id }) => id); +const DEFAULT_MODEL = 'qwen3.8-27b'; +const DEFAULT_PORT = 18020; + +const endpointForPeer = (peer) => { + const rawHost = String(peer?.host || peer?.address || '').trim(); + if (!rawHost) return ''; + const candidate = /^https?:\/\//i.test(rawHost) ? rawHost : `http://${rawHost}`; + const parsedHost = URL.canParse(candidate) ? new URL(candidate).hostname : rawHost; + const host = parsedHost.replace(/^\[|\]$/g, '').replace(/\.$/, ''); + return `http://${host}:${DEFAULT_PORT}/v1`; +}; + +const normalizeEndpoint = (value) => { + const trimmed = String(value || '').trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + return /\/v\d+$/i.test(withScheme) ? withScheme : `${withScheme}/v1`; +}; + +/** + * Build the provider record created by the fleet walkthrough. + * + * The endpoint intentionally appears twice on an OpenCode record: the provider + * field drives PortOS model refresh/readiness, while OPENCODE_CONFIG_CONTENT is + * what the spawned harness actually uses. Updating only the former paints a + * correct-looking remote card whose agent still calls localhost. + */ +export const buildFleetProvider = ({ name, endpoint, apiKey, model, harness }) => { + const common = { + name: name.trim(), + endpoint, + apiKey: apiKey.trim(), + models: [model.trim()], + defaultModel: model.trim(), + vllmBacked: true, + temperature: 0.7, + topP: 0.8, + thinking: false, + timeout: 600000, + enabled: true, + }; + if (harness === 'api') return { ...common, type: 'api' }; + return { + ...common, + type: 'tui', + command: 'opencode', + args: [], + envVars: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + permission: 'allow', + provider: { + vllm: { + npm: '@ai-sdk/openai-compatible', + name: 'Fleet vLLM Qwen3.8-27B', + options: { baseURL: endpoint }, + }, + }, + }), + }, + secretEnvVars: [], + tuiPromptDelayMs: 2500, + tuiIdleTimeoutMs: 180000, + }; +}; + +export default function FleetProviderSetup({ peers = [], onClose, onCreate }) { + const [activeTab, setActiveTab] = useDrawerTab('fleetStep', 'architecture', FLEET_TAB_IDS); + const [selectedPeerId, setSelectedPeerId] = useState(''); + const [endpointInput, setEndpointInput] = useState(''); + const [name, setName] = useState('Fleet GPU · OpenCode TUI'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(DEFAULT_MODEL); + const [harness, setHarness] = useState('tui'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const availablePeers = useMemo( + () => peers.filter((peer) => peer?.enabled !== false && (peer?.host || peer?.address)), + [peers], + ); + const endpoint = normalizeEndpoint(endpointInput); + + const selectPeer = (peerId) => { + setSelectedPeerId(peerId); + const peer = availablePeers.find(({ id }) => id === peerId); + setEndpointInput(peer ? endpointForPeer(peer) : ''); + }; + + const selectHarness = (next) => { + setHarness(next); + setName(next === 'tui' ? 'Fleet GPU · OpenCode TUI' : 'Fleet GPU · API'); + }; + + const submit = (event) => { + event.preventDefault(); + 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)) { + 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 })) + .then(onClose) + .catch((err) => setError(err?.message || 'Could not create the fleet provider.')) + .finally(() => setSaving(false)); + }; + + return ( + + {activeTab === 'architecture' && ( +
+ +

Recommended for one RTX 3090: vLLM + Qwen3.8-27B + DFlash2 on the host, OpenCode TUI on coding clients.

+

Use a direct API provider instead when PortOS only needs text synthesis. Both connect straight to the same authenticated OpenAI-compatible endpoint over Tailscale.

+
+ +
+ + + + +
+ +

+ The runtime is reached directly rather than proxied through PortOS. That avoids an extra hop and lets OpenCode use the standard OpenAI-compatible tool stream. +

+
+ )} + + {activeTab === 'host' && ( +
+ + Do this on the dedicated RTX 3090 PortOS instance. No model download or provider call happens from this walkthrough. + +
    +
  1. Open Load Samples on AI Providers and add OpenCode vLLM TUI (Qwen3.8-27B).
  2. +
  3. Use that card’s setup checklist to prepare the vLLM stack. Set SPEC=dflash2, PREFIX_CACHE=1, and a strong VLLM_API_KEY.
  4. +
  5. Keep the runtime bound on port 18020. The stack listens on the network; use Tailscale ACLs and the API key to limit clients.
  6. +
  7. Because this is a dedicated host, configure the container to restart unless stopped. Do not do that on a mixed media workstation: the model occupies nearly the whole GPU.
  8. +
  9. Confirm /v1/models answers through the host’s MagicDNS name or Tailscale IP before configuring clients.
  10. +
+ +
+ )} + + {activeTab === 'client' && ( +
+ + Create this provider on each client PortOS instance. The saved endpoint and the spawned OpenCode harness will point at the same fleet host. + + + {availablePeers.length > 0 && ( + + + + )} + + + { + setSelectedPeerId(''); + setEndpointInput(event.target.value); + }} + placeholder="http://gpu-host.example.ts.net:18020/v1" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" + /> + + + + + + +
+ + setName(event.target.value)} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" + /> + + + setModel(event.target.value)} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" + /> + +
+ + + setApiKey(event.target.value)} + autoComplete="off" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" + /> + + + {error && {error}} + +
+ Manage peers + +
+
+ )} + + {activeTab === 'verify' && ( +
+
    +
  1. Open the new card and click Refresh Models. Confirm the served id appears.
  2. +
  3. Click Test. A fleet badge should name the remote host and no local-runtime installer should appear.
  4. +
  5. For the TUI harness, click Launch in Shell and ask it to inspect a small workspace before assigning unattended tasks.
  6. +
  7. Set it as the default only after the tool-call test succeeds. Keep a cloud or local fallback for host maintenance and reboots.
  8. +
+ + A Tailscale connection protects transport inside the tailnet; the API key still prevents another tailnet process from using the model accidentally. Never copy the key into a shared issue, log, or provider name. + +
+ )} +
+ ); +} + +function RuntimeChoice({ title, badge, body }) { + return ( +
+
+

{title}

+ {badge} +
+

{body}

+
+ ); +} diff --git a/client/src/components/providers/ProviderCard.jsx b/client/src/components/providers/ProviderCard.jsx index 0c11ba82f7..e7961d95b1 100644 --- a/client/src/components/providers/ProviderCard.jsx +++ b/client/src/components/providers/ProviderCard.jsx @@ -12,7 +12,7 @@ */ import { Link } from 'react-router'; -import { Terminal } from 'lucide-react'; +import { Network, Terminal } from 'lucide-react'; import { CONTEXT_WINDOW_SOURCE, PROVIDER_CARD_STATE, @@ -20,6 +20,7 @@ import { isApiProvider, gatewayForProvider, isPrivateNetworkEndpoint, + isFleetProvider, isProcessProvider, isRunnerAllowedCommand, isProviderHardwareCompatible, @@ -99,6 +100,10 @@ export default function ProviderCard({ }) { const style = CARD_STATE_STYLES[cardState.state]; const compatibleModels = filterHardwareCompatibleProviderModels(provider.models, provider); + const fleetProvider = isFleetProvider(provider); + const fleetHost = fleetProvider && URL.canParse(provider.endpoint) + ? new URL(provider.endpoint).hostname + : null; return (
)} + {fleetProvider && ( + + FLEET HOST + + )} {provider.llamaBacked && ( LLAMA.CPP / DFLASH @@ -322,7 +335,7 @@ export default function ProviderCard({ )} {provider.vllmBacked && (

- Local vLLM container (endpoint: {provider.endpoint}) — Qwen3.8-27B with DFlash 2 drafting. It holds the whole GPU, so stop it before running local image/video generation. + {fleetProvider ? 'Fleet vLLM runtime' : 'Local vLLM container'} (endpoint: {provider.endpoint}) — Qwen3.8-27B with DFlash 2 drafting. {fleetProvider ? 'This PortOS sends work over the private network; runtime lifecycle stays on the GPU host.' : 'It holds the whole GPU, so stop it before running local image/video generation.'}

)} {provider.sglangBacked && ( @@ -336,6 +349,11 @@ export default function ProviderCard({ {isApiProvider(provider) && (

Endpoint: {provider.endpoint}

)} + {fleetProvider && ( +

+ Runs on {fleetHost || 'another private-network machine'}; install, start, and GPU-memory controls belong to that host. +

+ )} {/* API-type providers auth solely via the stored apiKey (sent as a Bearer header) — surface its state here so "where does the key go?" is answered from the card, not by spelunking the form. */} diff --git a/client/src/components/providers/ProviderCard.test.jsx b/client/src/components/providers/ProviderCard.test.jsx index e425e031d8..ef2481acc4 100644 --- a/client/src/components/providers/ProviderCard.test.jsx +++ b/client/src/components/providers/ProviderCard.test.jsx @@ -62,3 +62,23 @@ describe('ProviderCard context window', () => { expect(screen.queryByText(/assumed/)).toBeNull(); }); }); + +describe('ProviderCard fleet identity', () => { + it('decorates a private remote runtime and assigns lifecycle to that host', () => { + renderCard(wrapper({ + name: 'Fleet GPU', + endpoint: 'http://gpu-host.example.ts.net:18020/v1', + vllmBacked: true, + })); + + expect(screen.getByText('FLEET HOST')).toBeInTheDocument(); + expect(screen.getByText(/Fleet vLLM runtime/)).toBeInTheDocument(); + expect(screen.getByText(/Runs on/)).toHaveTextContent('gpu-host.example.ts.net'); + expect(screen.queryByText(/Local vLLM container/)).not.toBeInTheDocument(); + }); + + it('does not decorate a public hosted API as a fleet host', () => { + renderCard(wrapper({ endpoint: 'https://api.example.com/v1' })); + expect(screen.queryByText('FLEET HOST')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/AIProviders.jsx b/client/src/pages/AIProviders.jsx index 43d41d8ff9..5f07fd1b3e 100644 --- a/client/src/pages/AIProviders.jsx +++ b/client/src/pages/AIProviders.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router'; -import { AlertTriangle, Gauge } from 'lucide-react'; +import { AlertTriangle, Gauge, Network } from 'lucide-react'; import toast from '../components/ui/Toast'; import * as api from '../services/api'; import socket from '../services/socket'; @@ -27,6 +27,7 @@ import RuntimeInstallModal from '../components/install/RuntimeInstallModal'; import ProviderCard from '../components/providers/ProviderCard'; import { GatewayKeyHint } from '../components/providers/ProviderNotices'; import CollapsibleSection from '../components/ui/CollapsibleSection'; +import FleetProviderSetup from '../components/providers/FleetProviderSetup'; // The two local apps an API provider can front. Their installer lives on the // Models → LLMs page (it starts the service too), so the provider card @@ -129,6 +130,7 @@ export default function AIProviders() { const [sampleProviders, setSampleProviders] = useState([]); const [loadingSamples, setLoadingSamples] = useState(false); const [addingSample, setAddingSample] = useState({}); + const [fleetPeers, setFleetPeers] = useState([]); // Samples this machine could actually run. One the server marked // hardware-`unavailable` has no path to becoming usable here, so it is not // listed at all rather than listed with a dead "Unavailable" button. @@ -174,6 +176,7 @@ export default function AIProviders() { const location = useLocation(); const { providerId: editingProviderId } = useParams(); const creatingProvider = location.pathname.replace(/\/+$/, '').endsWith('/ai/new'); + const fleetSetupOpen = location.pathname.replace(/\/+$/, '').endsWith('/ai/fleet'); const closeForm = useCallback(() => navigate('/ai'), [navigate]); const openForm = useCallback((target) => navigate(target ? `/ai/edit/${target.id}` : '/ai/new'), [navigate]); @@ -191,6 +194,13 @@ export default function AIProviders() { useEffect(() => { loadRuntimes(); }, [loadRuntimes]); + useEffect(() => { + if (!fleetSetupOpen) return; + api.getInstances({ silent: true }) + .then((data) => setFleetPeers(Array.isArray(data?.peers) ? data.peers : [])) + .catch(() => setFleetPeers([])); + }, [fleetSetupOpen]); + useEffect(() => { if (!activeRun) return; @@ -415,6 +425,13 @@ export default function AIProviders() { } }; + const handleCreateFleetProvider = async (provider) => { + const created = await api.createProvider(provider); + setProviders((current) => [...current, created]); + toast.success(`${created.name} is connected to the fleet GPU host`); + return created; + }; + const handleAddAllSamples = async () => { if (addableSamples.length === 0) return; @@ -576,6 +593,12 @@ export default function AIProviders() { > Compare local models + + Fleet setup +