Skip to content

Commit edf07ec

Browse files
authored
fix(vllm): support LM Studio endpoints (#7036)
* fix(vllm): support LM Studio endpoints * fix(vllm): validate compatible base URLs * fix(vllm): guard discovery URL validation
1 parent fbeea53 commit edf07ec

12 files changed

Lines changed: 199 additions & 15 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/docker.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,26 @@ OLLAMA_URL=http://192.168.1.100:11434 docker compose -f docker-compose.prod.yml
134134
Inside Docker, `localhost` refers to the container, not your host. Use `host.docker.internal` or your host's IP.
135135
</Callout>
136136

137+
### LM Studio
138+
139+
[LM Studio exposes an OpenAI-compatible API](https://lmstudio.ai/docs/developer/openai-compat). Start its local server, load a model, and enable **Serve on Local Network** so the Docker container can reach it. [Enable API authentication](https://lmstudio.ai/docs/developer/core/authentication), then set the endpoint and token in the `.env` file next to your Compose file:
140+
141+
```bash
142+
# macOS/Windows
143+
VLLM_BASE_URL=http://host.docker.internal:1234
144+
145+
# Linux - use your host IP instead
146+
# VLLM_BASE_URL=http://192.168.1.100:1234
147+
148+
VLLM_API_KEY=your_lm_studio_api_token
149+
```
150+
151+
Both the server root shown above and a URL ending in `/v1` are accepted. After recreating the `simstudio` service, its models appear in the model picker with a `vllm/` prefix; Sim removes that prefix before sending the model identifier to LM Studio.
152+
153+
```bash
154+
docker compose -f docker-compose.ollama.yml up -d --force-recreate simstudio
155+
```
156+
137157
## Commands
138158

139159
```bash

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
8181

8282
| Variable | Description |
8383
|----------|-------------|
84-
| `VLLM_BASE_URL` | vLLM server URL, **without** a `/v1` suffix (e.g. `http://localhost:8000`) — Sim appends `/v1` itself |
85-
| `VLLM_API_KEY` | Optional bearer token for vLLM |
84+
| `VLLM_BASE_URL` | OpenAI-compatible vLLM or LM Studio URL. Both the server root (`http://localhost:8000`) and versioned API URL (`http://localhost:8000/v1`) are accepted |
85+
| `VLLM_API_KEY` | Optional bearer token for the vLLM or LM Studio endpoint |
8686
| `LITELLM_BASE_URL` | LiteLLM proxy base URL |
8787
| `LITELLM_API_KEY` | Optional bearer token for LiteLLM |
8888

apps/docs/content/docs/en/platform/self-hosting/index.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Sim is self-contained for the core editor and execution engine. A few features r
116116
| Feature | Requires | Notes |
117117
|---|---|---|
118118
| **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key | Embeddings are generated by a hosted provider, selected with `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). There is no local embedding backend — knowledge bases are unavailable without one of these keys. |
119-
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. |
119+
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, or LiteLLM. |
120120
| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. |
121121
| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). |
122122
| **Remote Function / Pi execution** | Optional E2B or Daytona key | Without one, JavaScript Function code that has no `import` or `require` still runs in the in-process isolated VM. Python, Shell, JavaScript with external imports, custom Function Sandboxes, and Pi require a configured remote provider. See [Security](/platform/self-hosting/security). |
@@ -126,4 +126,3 @@ Sim is self-contained for the core editor and execution engine. A few features r
126126
{ question: "What are the required environment variables for production?", answer: "Three secrets are required: BETTER_AUTH_SECRET (authentication), ENCRYPTION_KEY (data encryption), and INTERNAL_API_SECRET (service-to-service auth). Generate each with openssl rand -hex 32. You also need to set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to your domain."},
127127
{ question: "Can I use Sim with local AI models?", answer: "Yes. Sim supports Ollama for local model inference. Use docker-compose.ollama.yml instead of docker-compose.prod.yml. It offers both GPU (with NVIDIA support) and CPU-only profiles, and automatically pulls gemma3:4b as a starter model." },
128128
]} />
129-

apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ OLLAMA_URL=http://host.docker.internal:11434 # macOS/Windows
2525
OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP)
2626
```
2727

28+
## LM Studio Requests Route to Ollama
29+
30+
Sim identifies dynamically discovered LM Studio and vLLM models by their `vllm/` prefix. If the endpoint is unavailable and you manually enter the raw LM Studio model identifier, Sim treats that unknown identifier as an Ollama model.
31+
32+
1. Confirm `VLLM_BASE_URL` is available inside the app container:
33+
34+
```bash
35+
docker compose -f docker-compose.ollama.yml exec simstudio printenv VLLM_BASE_URL
36+
```
37+
38+
2. In LM Studio, enable **Serve on Local Network** and API authentication so the container can connect safely.
39+
3. From Docker on macOS or Windows, use `http://host.docker.internal:1234` rather than `localhost`. On Linux, use the host IP.
40+
4. The server root and a URL ending in `/v1` are both accepted.
41+
5. Recreate `simstudio`, reload the workspace, and select the discovered `vllm/<model-id>` option from the model picker.
42+
2843
## WebSocket/Realtime Not Working
2944

3045
1. Verify reverse proxy routes `/socket.io` to the realtime service (default port 3002). `NEXT_PUBLIC_SOCKET_URL` is only needed if realtime is on a separate host.

apps/sim/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
9090

9191
# Local AI Models (Optional)
9292
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
93-
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
94-
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
93+
# VLLM_BASE_URL=http://localhost:8000 # vLLM or LM Studio OpenAI-compatible URL; a trailing /v1 is optional
94+
# VLLM_API_KEY= # Optional bearer token if the endpoint requires auth
9595
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
9696
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
9797
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
8+
mockFetch: vi.fn(),
9+
mockFilterBlacklistedModels: vi.fn((models: string[]) => models),
10+
mockIsProviderBlacklisted: vi.fn(() => false),
11+
}))
12+
13+
vi.mock('@/providers/utils', () => ({
14+
filterBlacklistedModels: mockFilterBlacklistedModels,
15+
isProviderBlacklisted: mockIsProviderBlacklisted,
16+
}))
17+
18+
import { GET } from '@/app/api/providers/vllm/models/route'
19+
20+
const request = () => createMockRequest('GET')
21+
22+
describe('vLLM models route', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
26+
mockIsProviderBlacklisted.mockReturnValue(false)
27+
mockFetch.mockResolvedValue({
28+
ok: true,
29+
json: async () => ({ data: [{ id: 'local-model' }] }),
30+
})
31+
vi.stubGlobal('fetch', mockFetch)
32+
setEnv({ VLLM_BASE_URL: 'http://localhost:8000', VLLM_API_KEY: undefined })
33+
})
34+
35+
afterAll(() => {
36+
vi.unstubAllGlobals()
37+
resetEnvMock()
38+
})
39+
40+
it('discovers and prefixes models from a server-root URL', async () => {
41+
const response = await GET(request())
42+
43+
await expect(response.json()).resolves.toEqual({ models: ['vllm/local-model'] })
44+
expect(mockFetch).toHaveBeenCalledWith(
45+
'http://localhost:8000/v1/models',
46+
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } })
47+
)
48+
})
49+
50+
it('uses an existing /v1 prefix once and forwards bearer authentication', async () => {
51+
setEnv({ VLLM_BASE_URL: 'http://localhost:1234/v1', VLLM_API_KEY: 'lm-token' })
52+
53+
await GET(request())
54+
55+
expect(mockFetch).toHaveBeenCalledWith(
56+
'http://localhost:1234/v1/models',
57+
expect.objectContaining({
58+
headers: {
59+
Authorization: 'Bearer lm-token',
60+
'Content-Type': 'application/json',
61+
},
62+
})
63+
)
64+
})
65+
66+
it('returns an empty model list when the configured base URL is unsupported', async () => {
67+
setEnv({ VLLM_BASE_URL: 'http://localhost:1234?token=value' })
68+
69+
const response = await GET(request())
70+
71+
await expect(response.json()).resolves.toEqual({ models: [] })
72+
expect(mockFetch).not.toHaveBeenCalled()
73+
})
74+
})

apps/sim/app/api/providers/vllm/models/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@/lib/api/contracts/providers'
88
import { env } from '@/lib/core/config/env'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'
1011
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
1112

1213
const logger = createLogger('VLLMModelsAPI')
@@ -20,14 +21,15 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
2021
return NextResponse.json({ models: [] })
2122
}
2223

23-
const baseUrl = (env.VLLM_BASE_URL || '').replace(/\/$/, '')
24+
const baseUrl = env.VLLM_BASE_URL?.trim()
2425

2526
if (!baseUrl) {
2627
logger.info('VLLM_BASE_URL not configured')
2728
return NextResponse.json({ models: [] })
2829
}
2930

3031
try {
32+
const apiBaseUrl = getOpenAICompatibleApiBaseUrl(baseUrl)
3133
logger.info('Fetching vLLM models', {
3234
baseUrl,
3335
})
@@ -40,7 +42,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
4042
headers.Authorization = `Bearer ${env.VLLM_API_KEY}`
4143
}
4244

43-
const response = await fetch(`${baseUrl}/v1/models`, {
45+
const response = await fetch(`${apiBaseUrl}/models`, {
4446
headers,
4547
next: { revalidate: 60 },
4648
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'
3+
4+
describe('getOpenAICompatibleApiBaseUrl', () => {
5+
it.each([
6+
['http://localhost:8000', 'http://localhost:8000/v1'],
7+
['http://localhost:1234/v1', 'http://localhost:1234/v1'],
8+
['https://models.example.com/gateway/', 'https://models.example.com/gateway/v1'],
9+
['https://models.example.com/gateway/v1/', 'https://models.example.com/gateway/v1'],
10+
])('normalizes %s to %s', (input, expected) => {
11+
expect(getOpenAICompatibleApiBaseUrl(input)).toBe(expected)
12+
})
13+
14+
it.each(['http://localhost:8000?token=value', 'http://localhost:8000#models'])(
15+
'rejects unsupported URL components in %s',
16+
(input) => {
17+
expect(() => getOpenAICompatibleApiBaseUrl(input)).toThrow(
18+
'OpenAI-compatible base URL must not include query parameters or a fragment'
19+
)
20+
}
21+
)
22+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Normalizes a server root or versioned OpenAI-compatible URL to the `/v1` API base.
3+
*
4+
* @throws When the URL contains query parameters or a fragment.
5+
*/
6+
export function getOpenAICompatibleApiBaseUrl(baseUrl: string): string {
7+
const url = new URL(baseUrl.trim())
8+
if (url.search || url.hash) {
9+
throw new Error('OpenAI-compatible base URL must not include query parameters or a fragment')
10+
}
11+
12+
const pathname = url.pathname.replace(/\/+$/, '')
13+
url.pathname = pathname.endsWith('/v1') ? pathname : `${pathname}/v1`
14+
return url.toString()
15+
}

apps/sim/providers/vllm/index.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ describe('vllmProvider', () => {
166166
expect(openAIArgs[0].fetch).toBeUndefined()
167167
})
168168

169+
it('does not duplicate an existing /v1 API prefix', async () => {
170+
setEnv({ VLLM_BASE_URL: 'http://localhost:1234/v1', VLLM_API_KEY: undefined })
171+
mockCreate.mockResolvedValueOnce(chatResponse('hi'))
172+
173+
await vllmProvider.executeRequest({
174+
model: 'vllm/lmstudio-model',
175+
messages: [{ role: 'user', content: 'hi' }],
176+
})
177+
178+
expect(openAIArgs[0].baseURL).toBe('http://localhost:1234/v1')
179+
})
180+
169181
it('validates a user-supplied endpoint and pins the connection to the resolved IP', async () => {
170182
mockCreate.mockResolvedValueOnce(chatResponse('hi'))
171183

@@ -185,6 +197,24 @@ describe('vllmProvider', () => {
185197
expect(openAIArgs[0].fetch).toBe(pinnedFetchFn)
186198
})
187199

200+
it('preserves an existing /v1 prefix on a user-supplied endpoint', async () => {
201+
mockCreate.mockResolvedValueOnce(chatResponse('hi'))
202+
203+
await vllmProvider.executeRequest({
204+
model: 'vllm/llama-3',
205+
messages: [{ role: 'user', content: 'hi' }],
206+
azureEndpoint: 'https://my-vllm.example.com/v1',
207+
})
208+
209+
expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
210+
'https://my-vllm.example.com/v1',
211+
'vLLM endpoint',
212+
{ allowHttp: true }
213+
)
214+
expect(openAIArgs[0].baseURL).toBe('https://my-vllm.example.com/v1')
215+
expect(openAIArgs[0].fetch).toBe(pinnedFetchFn)
216+
})
217+
188218
it('rejects a user-supplied endpoint that fails SSRF validation without issuing a request', async () => {
189219
mockValidateUrlWithDNS.mockResolvedValueOnce({
190220
isValid: false,
@@ -236,7 +266,8 @@ describe('vllmProvider', () => {
236266
const payload = createPayload(0)
237267
expect(payload.model).toBe('llama-3')
238268
expect(payload.temperature).toBe(0.7)
239-
expect(payload.max_completion_tokens).toBe(256)
269+
expect(payload.max_tokens).toBe(256)
270+
expect(payload.max_completion_tokens).toBeUndefined()
240271
expect(payload.messages.map((m: { role: string }) => m.role)).toEqual([
241272
'system',
242273
'user',

0 commit comments

Comments
 (0)