Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,31 @@ async function handleNodeRemoveMember(
}
}

/**
* Adopt an externally-managed OpenAI-compatible endpoint by base URL.
* Relays `node/add` with `openai_base_url` to the broker, which forwards it to
* nvpair-manual-nodes (persisting the entry and probing the declared URL).
* The scheme check stays local for fast inline feedback; the service
* re-validates and is authoritative.
*/
async function handleAddOpenAIEndpoint(
payload?: WsInvokeRequest<'nodes:add-endpoint'>
): Promise<WsInvokeResponse<'nodes:add-endpoint'>> {
const url = payload?.url?.trim() ?? ''
if (!/^https?:\/\//.test(url)) {
return {
ok: false,
error: 'Enter a full endpoint URL, e.g. http://192.168.1.50:8888/v1 (https not supported yet)'
}
}
try {
await getModularSupervisor().callProcess('broker', 'node/add', { openai_base_url: url })
return { ok: true }
} catch (err) {
return { ok: false, error: getErrorString(err) }
}
}

const EMPTY_SERVICE_BRIDGE_HANDLERS: BridgeHandlerMap = {
'app:get-initial': async () => ({
connected: getModularSupervisor().ready,
Expand All @@ -952,6 +977,7 @@ const EMPTY_SERVICE_BRIDGE_HANDLERS: BridgeHandlerMap = {

'nodes:get-initial': () => getModularBridgeState().getNodesInitial(),
'nodes:remove-member': payload => handleNodeRemoveMember(payload),
'nodes:add-endpoint': payload => handleAddOpenAIEndpoint(payload),

'discovery:get-nodes': () => getModularBridgeState().getAvailableNodes(),

Expand Down
8 changes: 6 additions & 2 deletions desktop/src/electron/service-bridge/modular-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,10 +403,14 @@ function pendingEngineOpIdleTimeoutMs(status: EngineProcessStatus): number {

/**
* Map a `nvpair-engine-manager` engine identifier onto our closed `EngineType`
* union. The engine-manager uses `lmstudio`; we use `lm-studio`.
* union. The engine-manager uses `lmstudio`; we use `lm-studio`. External
* OpenAI-compatible endpoints report engine `openai`; they render through the
* existing lm-studio display path (no dedicated icon/badge — cosmetic, same as
* the node-card engine chip) so their workloads are not silently dropped by
* the closed-union guard.
*/
function engineManagerEngineType(name: string): EngineType | null {
const normalized = name === 'lmstudio' ? 'lm-studio' : name
const normalized = name === 'lmstudio' || name === 'openai' ? 'lm-studio' : name
return isEngineType(normalized) ? normalized : null
}

Expand Down
6 changes: 6 additions & 0 deletions desktop/src/shared/types/ws-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export interface WsInvokeChannelMap {
request: { nodeId: string }
response: { nodeId: string; removed: boolean }
}
// Adopt an externally-managed OpenAI-compatible endpoint by base URL
// (relayed to the broker's `node/add`; nvpair-manual-nodes persists it).
'nodes:add-endpoint': {
request: { url: string }
response: { ok: boolean; error?: string }
}

// Discovery
'discovery:get-nodes': { request: void; response: AvailableNode[] }
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/ui/api/pair-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface INodesApi {
}>
/** Remove a node from the cluster (revokes membership + pinned trust). */
removeMember(nodeId: string): Promise<{ nodeId: string; removed: boolean }>
/** Adopt an externally-managed OpenAI-compatible endpoint by base URL. */
addEndpoint(url: string): Promise<{ ok: boolean; error?: string }>
/** A node was added or updated in the discovery/metrics list. */
onUpsert(callback: (node: NodeItem) => void): () => void
/** A node was removed from the discovery/metrics list. */
Expand Down Expand Up @@ -146,6 +148,7 @@ export function createPairApi(transport: ServiceTransport): IPairApi {
}
},
removeMember: nodeId => transport.invoke('nodes:remove-member', { nodeId }),
addEndpoint: url => transport.invoke('nodes:add-endpoint', { url }),
onUpsert: cb => transport.subscribePush('nodes:upsert', cb),
onRemove: cb => transport.subscribePush('nodes:remove', cb),
onMembersChanged: cb => transport.subscribePush('nodes:changed', cb)
Expand Down
58 changes: 58 additions & 0 deletions desktop/src/ui/components/AddNodeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@nvidia/foundations-react-core'
import { DialogHeader } from './DialogHeader'
import { InvitePairingPanel } from './InvitePairingPanel'
import getErrorString from '@/shared/utils/get-error-string'
import { useBlurOnOpen } from '@/ui/hooks/useBlurOnOpen'
import { useInvitePairing } from '@/ui/hooks/useInvitePairing'
import { useInvitablePeers } from '@/ui/hooks/useInvitablePeers'
Expand All @@ -28,12 +29,17 @@ interface AddNodeModalProps {
export function AddNodeModal({ open, onOpenChange }: AddNodeModalProps) {
useBlurOnOpen(open)
const [manualIp, setManualIp] = useState('')
const [endpointUrl, setEndpointUrl] = useState('')
const [endpointError, setEndpointError] = useState<string | null>(null)
const [endpointInFlight, setEndpointInFlight] = useState(false)
const pairing = useInvitePairing()
const nodesThatCanBeAdded = useInvitablePeers()

const handleOpenChange = useCallback(
(next: boolean) => {
setManualIp('')
setEndpointUrl('')
setEndpointError(null)
pairing.reset()
onOpenChange(next)
},
Expand All @@ -46,6 +52,25 @@ export function AddNodeModal({ open, onOpenChange }: AddNodeModalProps) {
void pairing.start(ip)
}, [manualIp, pairing])

const handleAddEndpoint = useCallback(async () => {
const url = endpointUrl.trim()
if (!url || endpointInFlight) return
setEndpointInFlight(true)
setEndpointError(null)
try {
const result = await window.pairApi.nodes.addEndpoint(url)
if (result.ok) {
handleOpenChange(false)
} else {
setEndpointError(result.error ?? 'Failed to add the endpoint.')
}
} catch (err) {
setEndpointError(getErrorString(err))
} finally {
setEndpointInFlight(false)
}
}, [endpointUrl, endpointInFlight, handleOpenChange])

const showPairing = pairing.invite !== null || pairing.error !== null
const inviteInFlight = pairing.submitting || pairing.invite?.state === 'pending'

Expand Down Expand Up @@ -94,6 +119,39 @@ export function AddNodeModal({ open, onOpenChange }: AddNodeModalProps) {
</Button>
</Flex>

<Stack gap="1" className="mt-1">
<Divider />
<Flex align="end" gap="2">
<FormField slotLabel="OpenAI endpoint" className="flex-1">
<TextInput
value={endpointUrl}
onValueChange={value => {
setEndpointUrl(value)
setEndpointError(null)
}}
placeholder="http://192.168.1.50:8888/v1"
onKeyDown={event => {
if (event.key === 'Enter') {
void handleAddEndpoint()
}
}}
disabled={endpointInFlight}
/>
</FormField>
<Button
kind="primary"
color="brand"
onClick={() => void handleAddEndpoint()}
disabled={!endpointUrl.trim() || endpointInFlight}
>
Add
</Button>
</Flex>
{endpointError && (
<Text kind="body/regular/sm">{endpointError}</Text>
)}
</Stack>

{nodesThatCanBeAdded.length > 0 && (
<Stack gap="4" className="mt-1">
<Divider />
Expand Down
67 changes: 67 additions & 0 deletions desktop/tests/modular/openai-endpoint-add.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
state: {
getSelfId: vi.fn(() => 'local-node')
},
supervisor: {
callProcess: vi.fn(),
sendProcess: vi.fn(),
reportError: vi.fn()
}
}))

vi.mock('@/electron/service-bridge/modular-supervisor', () => ({
getModularSupervisor: () => mocks.supervisor
}))
vi.mock('@/electron/service-bridge/modular-state', () => ({
getModularBridgeState: () => mocks.state,
isProxyEngine: () => false,
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => [],
parseWorkloadsInitial: () => []
}))
vi.mock('@/electron/model-hub', () => ({ getEngineHubModels: vi.fn() }))

import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'

describe('nodes:add-endpoint (adopt an external OpenAI endpoint by URL)', () => {
it('rejects a URL without a scheme before touching the service', async () => {
const result = await handleServiceBridgeInvoke('nodes:add-endpoint', {
url: 'localhost:8888'
})

expect(result.ok).toBe(false)
expect(result.error).toMatch(/http:\/\//)
expect(mocks.supervisor.callProcess).not.toHaveBeenCalled()
})

it('relays a full http URL to the broker as node/add with openai_base_url', async () => {
mocks.supervisor.callProcess.mockResolvedValue(undefined)

const result = await handleServiceBridgeInvoke('nodes:add-endpoint', {
url: ' http://192.168.1.50:8888/v1 '
})

expect(mocks.supervisor.callProcess).toHaveBeenCalledWith('broker', 'node/add', {
openai_base_url: 'http://192.168.1.50:8888/v1'
})
expect(result).toEqual({ ok: true })
})

it('surfaces a service failure as an inline error, not a thrown rejection', async () => {
mocks.supervisor.callProcess.mockRejectedValue(
new Error('node already registered for this endpoint')
)

const result = await handleServiceBridgeInvoke('nodes:add-endpoint', {
url: 'http://192.168.1.50:8888/v1'
})

expect(result.ok).toBe(false)
expect(result.error).toContain('node already registered')
})
})
60 changes: 60 additions & 0 deletions desktop/tests/modular/openai-engine-workload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() }))

import { parseWorkloadsInitial } from '@/electron/service-bridge/modular-state'

// External OpenAI-compatible endpoints report the workload engine "openai".
// The desktop's EngineType union is closed; the mapping must render those
// workloads through the existing lm-studio path instead of silently dropping
// them at the unknown-engine guard.
describe('workload engine mapping for external endpoints', () => {
it('keeps an "openai" workload, rendered as lm-studio', () => {
const workloads = parseWorkloadsInitial({
workloads: [
{
id: 'job-openai',
engine: 'openai',
state: 'running',
model: 'stub-model',
originatedFrom: 'openai-wl-seed',
createdAt: 100
}
]
})

expect(workloads).toHaveLength(1)
expect(workloads[0].engine).toBe('lm-studio')
})

it('still maps the engine-manager "lmstudio" id and drops unknown engines', () => {
const workloads = parseWorkloadsInitial({
workloads: [
{
id: 'job-lmstudio',
engine: 'lmstudio',
state: 'running',
model: 'local-model',
originatedFrom: 'openai-wl-seed',
createdAt: 100
},
{
id: 'job-mystery',
engine: 'mystery-engine',
state: 'running',
model: 'ghost-model',
originatedFrom: 'openai-wl-seed',
createdAt: 200
}
]
})

expect(workloads).toHaveLength(1)
expect(workloads[0].id).toBe('job-lmstudio')
expect(workloads[0].engine).toBe('lm-studio')
})
})
9 changes: 6 additions & 3 deletions docs/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -648,9 +648,12 @@ its address.

Some networks block or filter multicast, so discovery is not the only path in.
`nvpair-manual-nodes` takes an address you enter directly and probes it on a
fixed interval, and a manual node that answers is folded into the same directory
as a discovered one. It is initially keyed by the address you typed, and re-keyed
to the peer's real UUID as soon as that node reports it.
fixed interval, or an OpenAI-compatible endpoint you declare by base URL and
probes at that exact URL, and a manual node that answers is folded into the
same directory as a discovered one. It is initially keyed by the address you
typed, and re-keyed to the peer's real UUID as soon as that node reports it.
The service owns its entry list in the application data directory and restores
it at startup, so manual nodes survive a restart.

## Trust Boundaries

Expand Down
4 changes: 4 additions & 0 deletions services/lmstudio-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Nodes are represented throughout the protocol with this shape:
| `txt` | string[] | The discovery record's TXT pairs, carried verbatim |
| `models` | string[] | The node's LM Studio model inventory from the discovery snapshot. Model-bearing inference is eligible only when this list advertises the exact requested model ID. An omitted or empty list excludes the node from that request until inventory updates; it remains available for non-inference routes and model-list aggregation |
| `ip` | string | The single canonical LAN address to dial or display, resolved from the node's `ip=` TXT if present and otherwise the best-scored advertised IPv4. Stamped onto outbound `node/*` notifications so consumers agree with the address the proxy routes to |
| `base_path` | string | API path prefix (e.g. `/v1`) carried by an external OpenAI-compatible endpoint bridged with a declared base URL. The proxy joins it onto its own `/v1` root when forwarding requests and when fetching the node's model list. Absent for discovered nodes and for classic manual nodes, whose API is served at their own `/v1` root and forwards verbatim |

---

Expand Down Expand Up @@ -350,6 +351,7 @@ Add a node manually (for networks where mDNS is blocked). If the node ID already
**Request:**
```json
{"jsonrpc":"2.0","id":5,"method":"node/add-manual","params":{"id":"remote-server","host":"remote-server","port":1234,"addresses":["10.0.1.50"]}}
{"jsonrpc":"2.0","id":5,"method":"node/add-manual","params":{"id":"vllm-host","host":"vllm-host","port":8888,"addresses":["192.168.1.50"],"models":["llama3.1:8b"],"base_path":"/v1"}}
```

**Response:**
Expand All @@ -359,6 +361,8 @@ Add a node manually (for networks where mDNS is blocked). If the node ID already

The proxy emits a `node/discovered` notification (or `node/updated` if the node was already registered). Manual nodes are a separate overlay that discovery snapshots never touch — they persist until explicitly removed.

A manual node carrying `base_path` is an external OpenAI-compatible endpoint adopted by declared base URL: the proxy rewrites its `/v1`-prefixed paths onto the endpoint's root (see the Node Object) and labels the workloads it serves with `engine: "openai"`, since the serving software is not LM Studio.

#### `node/remove-manual`

Remove a previously added manual node.
Expand Down
7 changes: 7 additions & 0 deletions services/lmstudio-proxy/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ type Node struct {
// advertises the requested model; an empty list stays in discovery but is
// not an inference candidate until a later inventory update.
Models []string `json:"models,omitempty"`
// BasePath is the endpoint's API path prefix (e.g. "/v1") for external
// OpenAI-compatible endpoints bridged with a declared base URL. The proxy
// joins it onto its own /v1 root when forwarding. Empty for every
// discovered node and for classic manual nodes, whose engines serve the
// OpenAI API at their own /v1 root.
BasePath string `json:"base_path,omitempty"`
// IP is the single canonical LAN address a consumer should dial/display for
// this node, resolved via the shared netpick ranker: the node's
// own ip= TXT if present, else the best-scored advertised IPv4. It is
Expand Down Expand Up @@ -135,6 +141,7 @@ func (d *Discovery) SetSubscribed(nodes []Node) (discovered, updated, removed []
// warrants a node/updated.
func nodeEqual(a, b Node) bool {
return a.ID == b.ID && a.Host == b.Host && a.Port == b.Port && a.IP == b.IP &&
a.BasePath == b.BasePath &&
slices.Equal(a.Addresses, b.Addresses) && slices.Equal(a.TXT, b.TXT) &&
slices.Equal(a.Models, b.Models)
}
Expand Down
Loading