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.
+
+
+
Open Load Samples on AI Providers and add OpenCode vLLM TUI (Qwen3.8-27B).
+
Use that card’s setup checklist to prepare the vLLM stack. Set SPEC=dflash2, PREFIX_CACHE=1, and a strong VLLM_API_KEY.
+
Keep the runtime bound on port 18020. The stack listens on the network; use Tailscale ACLs and the API key to limit clients.
+
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.
+
Confirm /v1/models answers through the host’s MagicDNS name or Tailscale IP before configuring clients.
Open the new card and click Refresh Models. Confirm the served id appears.
+
Click Test. A fleet badge should name the remote host and no local-runtime installer should appear.
+
For the TUI harness, click Launch in Shell and ask it to inspect a small workspace before assigning unattended tasks.
+
Set it as the default only after the tool-call test succeeds. Keep a cloud or local fallback for host maintenance and reboots.
+
+
+ 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.
+
+
- 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.'}