Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# 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
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
# MOTHERSHIP_MODEL=litellm/gpt-5.4-mini # Run workspace chat locally instead of using the managed Mothership service
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/copilot/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types'
import { persistChatResources } from '@/lib/copilot/resources/persistence'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
Expand Down Expand Up @@ -660,9 +661,9 @@ async function resolveBranch(params: {
kind: 'workspace',
workspaceId: requestedWorkspaceId,
workspacePermission,
effectiveModel: DEFAULT_MODEL,
effectiveModel: env.MOTHERSHIP_MODEL ?? DEFAULT_MODEL,
goRoute: '/api/mothership',
titleModel: DEFAULT_MODEL,
titleModel: env.MOTHERSHIP_MODEL ?? DEFAULT_MODEL,
notifyWorkspaceStatus: true,
buildPayload: async (payloadParams) =>
buildCopilotRequestPayload(
Expand Down
44 changes: 44 additions & 0 deletions apps/sim/lib/copilot/request/lifecycle/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
mockGetMothershipBaseURL,
mockGetMothershipSourceEnvHeaders,
mockPrepareExecutionContext,
mockRunLocalMothershipLifecycle,
mockRunStreamLoop,
mockToolWatchdogTimeoutMs,
mockUpdateRunStatus,
Expand All @@ -22,6 +23,7 @@ const {
mockGetMothershipBaseURL: vi.fn(),
mockGetMothershipSourceEnvHeaders: vi.fn(),
mockPrepareExecutionContext: vi.fn(),
mockRunLocalMothershipLifecycle: vi.fn(),
mockRunStreamLoop: vi.fn(),
mockToolWatchdogTimeoutMs: vi.fn(() => 60_000),
mockUpdateRunStatus: vi.fn(),
Expand Down Expand Up @@ -92,17 +94,59 @@ vi.mock('@/lib/copilot/request/tools/executor', () => ({
toolWatchdogTimeoutMs: mockToolWatchdogTimeoutMs,
}))

vi.mock('@/lib/copilot/request/local/lifecycle', () => ({
runLocalMothershipLifecycle: mockRunLocalMothershipLifecycle,
}))

import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { CopilotBackendError } from '@/lib/copilot/request/go/stream'
import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
import { env } from '@/lib/core/config/env'

describe('runCopilotLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
;(env as { MOTHERSHIP_MODEL?: string }).MOTHERSHIP_MODEL = undefined
mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test')
mockGetMothershipSourceEnvHeaders.mockReturnValue({})
})

it('runs workspace Mothership locally when MOTHERSHIP_MODEL is configured', async () => {
;(env as { MOTHERSHIP_MODEL?: string }).MOTHERSHIP_MODEL = 'litellm/gpt-test'
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunLocalMothershipLifecycle.mockImplementation(
async (_payload, context: StreamingContext) => {
context.accumulatedContent = 'local answer'
context.finalAssistantContent = 'local answer'
context.contentBlocks.push({ type: 'text', content: 'local answer', timestamp: 1 })
}
)

const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
goRoute: '/api/mothership',
executionContext,
}
)

expect(mockRunLocalMothershipLifecycle).toHaveBeenCalledOnce()
expect(mockRunStreamLoop).not.toHaveBeenCalled()
expect(mockGetMothershipBaseURL).not.toHaveBeenCalled()
expect(result).toMatchObject({ success: true, content: 'local answer' })
})

it('runs cancelled completion persistence when a stream throws after abort', async () => {
const abortController = new AbortController()
abortController.abort('stop')
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/copilot/request/lifecycle/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ export async function runCopilotLifecycle(
let onCompleteStarted = false

try {
await runCheckpointLoop(requestPayload, context, execContext, lifecycleOptions, goRoute)
if (goRoute === '/api/mothership' && env.MOTHERSHIP_MODEL) {
const { runLocalMothershipLifecycle } = await import('@/lib/copilot/request/local/lifecycle')
await runLocalMothershipLifecycle(requestPayload, context, execContext, lifecycleOptions)
} else {
await runCheckpointLoop(requestPayload, context, execContext, lifecycleOptions, goRoute)
}

const result: OrchestratorResult = {
success: context.errors.length === 0 && !context.wasAborted,
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/copilot/request/lifecycle/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,10 @@ export async function requestChatTitle(params: {
}): Promise<string | null> {
const { message, model, provider, userId, workspaceId, otelContext } = params
if (!message || !model) return null
if (env.MOTHERSHIP_MODEL && model === env.MOTHERSHIP_MODEL) {
const { requestLocalChatTitle } = await import('@/lib/copilot/request/local/title')
return requestLocalChatTitle(message)
}

const headers: Record<string, string> = {
'Content-Type': 'application/json',
Expand Down
110 changes: 110 additions & 0 deletions apps/sim/lib/copilot/request/local/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEnsureHandlersRegistered, mockExecuteProviderRequest, mockExecuteTool } = vi.hoisted(
() => ({
mockEnsureHandlersRegistered: vi.fn(),
mockExecuteProviderRequest: vi.fn(),
mockExecuteTool: vi.fn(),
})
)

vi.mock('@/lib/core/config/env', () => ({
env: { MOTHERSHIP_MODEL: 'litellm/test-model' },
}))

vi.mock('@/providers', () => ({
executeProviderRequest: mockExecuteProviderRequest,
}))

vi.mock('@/lib/copilot/tool-executor', () => ({
ensureHandlersRegistered: mockEnsureHandlersRegistered,
executeTool: mockExecuteTool,
}))

vi.mock('@/lib/copilot/request/local/messages', () => ({
buildLocalWorkspaceMessages: vi.fn().mockResolvedValue([{ role: 'user', content: 'hello' }]),
}))

vi.mock('@/lib/copilot/request/local/prompt', () => ({
buildLocalWorkspaceSystemPrompt: vi.fn(() => 'system prompt'),
}))

vi.mock('@/lib/copilot/request/local/tools', () => ({
buildLocalWorkspaceTools: vi.fn(() => [
{
id: 'user_table',
name: 'user_table',
description: 'table tool',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
},
]),
}))

import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
import { runLocalMothershipLifecycle } from './lifecycle'

describe('runLocalMothershipLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExecuteTool.mockResolvedValue({ success: true, output: { rows: [{ name: 'Ada' }] } })
mockExecuteProviderRequest.mockImplementation(async (_provider, request) => {
await request.toolExecutor({
toolCallId: 'call-1',
toolId: 'user_table',
params: { operation: 'get_rows', tableId: 'table-1' },
})
return {
content: 'The table contains Ada.',
model: 'litellm/test-model',
tokens: { input: 10, output: 5, total: 15 },
cost: { input: 0.01, output: 0.02, total: 0.03 },
}
})
})

it('executes Sim tools and emits compatible tool and text events', async () => {
const context = createStreamingContext({ messageId: 'message-1' })
const onEvent = vi.fn()

await runLocalMothershipLifecycle(
{ message: 'Read the table.' },
context,
{
userId: 'user-1',
workflowId: '',
workspaceId: 'workspace-1',
chatId: 'chat-1',
},
{
userId: 'user-1',
workspaceId: 'workspace-1',
chatId: 'chat-1',
onEvent,
}
)

expect(mockEnsureHandlersRegistered).toHaveBeenCalledOnce()
expect(mockExecuteTool).toHaveBeenCalledWith(
'user_table',
{ operation: 'get_rows', tableId: 'table-1' },
expect.objectContaining({
userId: 'user-1',
workspaceId: 'workspace-1',
copilotToolExecution: true,
})
)
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['tool', 'tool', 'text'])
expect(context.accumulatedContent).toBe('The table contains Ada.')
expect(context.toolCalls.get('call-1')).toMatchObject({
name: 'user_table',
status: 'success',
result: { success: true, output: { rows: [{ name: 'Ada' }] } },
})
expect(context.usage).toEqual({ prompt: 10, completion: 5 })
expect(context.cost).toEqual({ input: 0.01, output: 0.02, total: 0.03 })
})
})
Loading