Skip to content
Merged
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
20 changes: 20 additions & 0 deletions apps/docs/content/docs/en/platform/self-hosting/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,26 @@ OLLAMA_URL=http://192.168.1.100:11434 docker compose -f docker-compose.prod.yml
Inside Docker, `localhost` refers to the container, not your host. Use `host.docker.internal` or your host's IP.
</Callout>

### LM Studio

[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:

```bash
# macOS/Windows
VLLM_BASE_URL=http://host.docker.internal:1234

# Linux - use your host IP instead
# VLLM_BASE_URL=http://192.168.1.100:1234

VLLM_API_KEY=your_lm_studio_api_token
```

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.

```bash
docker compose -f docker-compose.ollama.yml up -d --force-recreate simstudio
```

## Commands

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ import { Callout } from 'fumadocs-ui/components/callout'

| Variable | Description |
|----------|-------------|
| `VLLM_BASE_URL` | vLLM server URL, **without** a `/v1` suffix (e.g. `http://localhost:8000`) — Sim appends `/v1` itself |
| `VLLM_API_KEY` | Optional bearer token for vLLM |
| `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 |
| `VLLM_API_KEY` | Optional bearer token for the vLLM or LM Studio endpoint |
| `LITELLM_BASE_URL` | LiteLLM proxy base URL |
| `LITELLM_API_KEY` | Optional bearer token for LiteLLM |

Expand Down
3 changes: 1 addition & 2 deletions apps/docs/content/docs/en/platform/self-hosting/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Sim is self-contained for the core editor and execution engine. A few features r
| Feature | Requires | Notes |
|---|---|---|
| **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. |
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. |
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, or LiteLLM. |
| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. |
| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). |
| **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). |
Expand All @@ -126,4 +126,3 @@ Sim is self-contained for the core editor and execution engine. A few features r
{ 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."},
{ 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." },
]} />

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ OLLAMA_URL=http://host.docker.internal:11434 # macOS/Windows
OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP)
```

## LM Studio Requests Route to Ollama

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.

1. Confirm `VLLM_BASE_URL` is available inside the app container:

```bash
docker compose -f docker-compose.ollama.yml exec simstudio printenv VLLM_BASE_URL
```

2. In LM Studio, enable **Serve on Local Network** and API authentication so the container can connect safely.
3. From Docker on macOS or Windows, use `http://host.docker.internal:1234` rather than `localhost`. On Linux, use the host IP.
4. The server root and a URL ending in `/v1` are both accepted.
5. Recreate `simstudio`, reload the workspace, and select the discovered `vllm/<model-id>` option from the model picker.

## WebSocket/Realtime Not Working

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.
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic

# Local AI Models (Optional)
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# VLLM_BASE_URL=http://localhost:8000 # vLLM or LM Studio OpenAI-compatible URL; a trailing /v1 is optional
# VLLM_API_KEY= # Optional bearer token if the endpoint requires auth
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
Expand Down
74 changes: 74 additions & 0 deletions apps/sim/app/api/providers/vllm/models/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @vitest-environment node
*/
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockFilterBlacklistedModels: vi.fn((models: string[]) => models),
mockIsProviderBlacklisted: vi.fn(() => false),
}))

vi.mock('@/providers/utils', () => ({
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))

import { GET } from '@/app/api/providers/vllm/models/route'

const request = () => createMockRequest('GET')

describe('vLLM models route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
mockIsProviderBlacklisted.mockReturnValue(false)
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: 'local-model' }] }),
})
vi.stubGlobal('fetch', mockFetch)
setEnv({ VLLM_BASE_URL: 'http://localhost:8000', VLLM_API_KEY: undefined })
})

afterAll(() => {
vi.unstubAllGlobals()
resetEnvMock()
})

it('discovers and prefixes models from a server-root URL', async () => {
const response = await GET(request())

await expect(response.json()).resolves.toEqual({ models: ['vllm/local-model'] })
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8000/v1/models',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } })
)
})

it('uses an existing /v1 prefix once and forwards bearer authentication', async () => {
setEnv({ VLLM_BASE_URL: 'http://localhost:1234/v1', VLLM_API_KEY: 'lm-token' })

await GET(request())

expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:1234/v1/models',
expect.objectContaining({
headers: {
Authorization: 'Bearer lm-token',
'Content-Type': 'application/json',
},
})
)
})

it('returns an empty model list when the configured base URL is unsupported', async () => {
setEnv({ VLLM_BASE_URL: 'http://localhost:1234?token=value' })

const response = await GET(request())

await expect(response.json()).resolves.toEqual({ models: [] })
expect(mockFetch).not.toHaveBeenCalled()
})
})
6 changes: 4 additions & 2 deletions apps/sim/app/api/providers/vllm/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@/lib/api/contracts/providers'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'

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

const baseUrl = (env.VLLM_BASE_URL || '').replace(/\/$/, '')
const baseUrl = env.VLLM_BASE_URL?.trim()

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

try {
const apiBaseUrl = getOpenAICompatibleApiBaseUrl(baseUrl)
logger.info('Fetching vLLM models', {
baseUrl,
})
Expand All @@ -40,7 +42,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
headers.Authorization = `Bearer ${env.VLLM_API_KEY}`
}

const response = await fetch(`${baseUrl}/v1/models`, {
const response = await fetch(`${apiBaseUrl}/models`, {
headers,
next: { revalidate: 60 },
})
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/providers/openai-compat/base-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'

describe('getOpenAICompatibleApiBaseUrl', () => {
it.each([
['http://localhost:8000', 'http://localhost:8000/v1'],
['http://localhost:1234/v1', 'http://localhost:1234/v1'],
['https://models.example.com/gateway/', 'https://models.example.com/gateway/v1'],
['https://models.example.com/gateway/v1/', 'https://models.example.com/gateway/v1'],
])('normalizes %s to %s', (input, expected) => {
expect(getOpenAICompatibleApiBaseUrl(input)).toBe(expected)
})

it.each(['http://localhost:8000?token=value', 'http://localhost:8000#models'])(
'rejects unsupported URL components in %s',
(input) => {
expect(() => getOpenAICompatibleApiBaseUrl(input)).toThrow(
'OpenAI-compatible base URL must not include query parameters or a fragment'
)
}
)
})
15 changes: 15 additions & 0 deletions apps/sim/providers/openai-compat/base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Normalizes a server root or versioned OpenAI-compatible URL to the `/v1` API base.
*
* @throws When the URL contains query parameters or a fragment.
*/
export function getOpenAICompatibleApiBaseUrl(baseUrl: string): string {
const url = new URL(baseUrl.trim())
if (url.search || url.hash) {
throw new Error('OpenAI-compatible base URL must not include query parameters or a fragment')
}

const pathname = url.pathname.replace(/\/+$/, '')
url.pathname = pathname.endsWith('/v1') ? pathname : `${pathname}/v1`
return url.toString()
}
33 changes: 32 additions & 1 deletion apps/sim/providers/vllm/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ describe('vllmProvider', () => {
expect(openAIArgs[0].fetch).toBeUndefined()
})

it('does not duplicate an existing /v1 API prefix', async () => {
setEnv({ VLLM_BASE_URL: 'http://localhost:1234/v1', VLLM_API_KEY: undefined })
mockCreate.mockResolvedValueOnce(chatResponse('hi'))

await vllmProvider.executeRequest({
model: 'vllm/lmstudio-model',
messages: [{ role: 'user', content: 'hi' }],
})

expect(openAIArgs[0].baseURL).toBe('http://localhost:1234/v1')
})

it('validates a user-supplied endpoint and pins the connection to the resolved IP', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('hi'))

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

it('preserves an existing /v1 prefix on a user-supplied endpoint', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('hi'))

await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
azureEndpoint: 'https://my-vllm.example.com/v1',
})

expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
'https://my-vllm.example.com/v1',
'vLLM endpoint',
{ allowHttp: true }
)
expect(openAIArgs[0].baseURL).toBe('https://my-vllm.example.com/v1')
expect(openAIArgs[0].fetch).toBe(pinnedFetchFn)
})

it('rejects a user-supplied endpoint that fails SSRF validation without issuing a request', async () => {
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
Expand Down Expand Up @@ -236,7 +266,8 @@ describe('vllmProvider', () => {
const payload = createPayload(0)
expect(payload.model).toBe('llama-3')
expect(payload.temperature).toBe(0.7)
expect(payload.max_completion_tokens).toBe(256)
expect(payload.max_tokens).toBe(256)
expect(payload.max_completion_tokens).toBeUndefined()
expect(payload.messages.map((m: { role: string }) => m.role)).toEqual([
'system',
'user',
Expand Down
15 changes: 9 additions & 6 deletions apps/sim/providers/vllm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatMessagesForProvider } from '@/providers/attachments'
import { getCachedProviderClient } from '@/providers/client-cache'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history'
import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'
import { executeProviderTool } from '@/providers/runtime-context'
import { createSettledAgentEventStream } from '@/providers/stream-events'
import { createStreamingExecution } from '@/providers/streaming-execution'
Expand Down Expand Up @@ -53,13 +54,14 @@ export const vllmProvider: ProviderConfig = {
return
}

const baseUrl = (env.VLLM_BASE_URL || '').replace(/\/$/, '')
const baseUrl = env.VLLM_BASE_URL?.trim()
if (!baseUrl) {
logger.info('VLLM_BASE_URL not configured, skipping initialization')
return
}

try {
const apiBaseUrl = getOpenAICompatibleApiBaseUrl(baseUrl)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
Expand All @@ -68,7 +70,7 @@ export const vllmProvider: ProviderConfig = {
headers.Authorization = `Bearer ${env.VLLM_API_KEY}`
}

const response = await fetch(`${baseUrl}/v1/models`, { headers })
const response = await fetch(`${apiBaseUrl}/models`, { headers })
if (!response.ok) {
await response.text().catch(() => {})
useProvidersStore.getState().setProviderModels('vllm', [])
Expand Down Expand Up @@ -105,10 +107,11 @@ export const vllmProvider: ProviderConfig = {

const userProvidedEndpoint = request.azureEndpoint

const baseUrl = (userProvidedEndpoint || env.VLLM_BASE_URL || '').replace(/\/$/, '')
const baseUrl = (userProvidedEndpoint || env.VLLM_BASE_URL)?.trim()
if (!baseUrl) {
throw new Error('VLLM_BASE_URL is required for vLLM provider')
}
const apiBaseUrl = getOpenAICompatibleApiBaseUrl(baseUrl)

/**
* A user-supplied endpoint is attacker-controlled: validate it against the
Expand Down Expand Up @@ -142,12 +145,12 @@ export const vllmProvider: ProviderConfig = {

const apiKey = request.apiKey || env.VLLM_API_KEY || 'empty'
const vllm = getCachedProviderClient(
`vllm::${apiKey}::${baseUrl}::${pinnedIP ?? 'no-pin'}`,
`vllm::${apiKey}::${apiBaseUrl}::${pinnedIP ?? 'no-pin'}`,
() =>
new OpenAI({
...openAICompatTransport(),
apiKey,
baseURL: `${baseUrl}/v1`,
baseURL: apiBaseUrl,
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
})
)
Expand Down Expand Up @@ -183,7 +186,7 @@ export const vllmProvider: ProviderConfig = {
}

if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.maxTokens != null) payload.max_tokens = request.maxTokens

if (request.responseFormat) {
payload.response_format = {
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.ollama.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: sim-with-ollama
services:
# Main Sim Application
simstudio:
env_file:
- path: .env
required: false
build:
context: .
dockerfile: docker/app.Dockerfile
Expand Down
Loading