chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,617 @@
import '@sim/testing/mocks/executor'
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential',
type: 'oauth',
workspaceId: 'test-workspace',
accountId: 'test-vertex-credential-id',
},
member: { role: 'admin', status: 'active' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
}),
}))
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import { BlockType } from '@/executor/constants'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
describe('RouterBlockHandler', () => {
let handler: RouterBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'target', name: 'Option A', description: 'Choose A' },
position: { x: 100, y: 100 },
config: { tool: 'tool_a', params: { p: 'a' } },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'target', name: 'Option B', description: 'Choose B' },
position: { x: 100, y: 150 },
config: { tool: 'tool_b', params: { p: 'b' } },
inputs: {},
outputs: {},
enabled: true,
}
mockBlock = {
id: 'router-block-1',
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER, params: {} },
inputs: { prompt: 'string', model: 'string' },
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-then1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'target-block-1',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: 0.003,
timing: { total: 300 },
}),
})
})
})
it('should handle router blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonRouterBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonRouterBlock)).toBe(false)
})
it('should execute router block correctly and select a path', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-api-key',
temperature: 0.1,
}
const expectedTargetBlocks = [
{
id: 'target-block-1',
type: 'target',
title: 'Option A',
description: 'Choose A',
subBlocks: {
p: 'a',
systemPrompt: '',
},
currentState: undefined,
},
{
id: 'target-block-2',
type: 'target',
title: 'Option B',
description: 'Choose B',
subBlocks: {
p: 'b',
systemPrompt: '',
},
currentState: undefined,
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(inputs.prompt, expectedTargetBlocks)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: expect.any(Object),
body: expect.any(String),
})
)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'openai',
model: 'gpt-4o',
systemPrompt: 'Generated System Prompt',
context: JSON.stringify([{ role: 'user', content: 'Choose the best option.' }]),
temperature: 0.1,
})
expect(result).toEqual({
prompt: 'Choose the best option.',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: {
input: 0,
output: 0,
total: 0,
},
selectedPath: {
blockId: 'target-block-1',
blockType: 'target',
blockTitle: 'Option A',
},
selectedRoute: 'target-block-1',
})
})
it('should throw error if target block is missing', async () => {
const inputs = { prompt: 'Test' }
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Target block target-block-1 not found'
)
expect(mockFetch).not.toHaveBeenCalled()
})
it('should throw error if LLM response is not a valid target block ID', async () => {
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'invalid-block-id',
model: 'mock-model',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Invalid routing decision: invalid-block-id'
)
})
it('should use default model and temperature if not provided', async () => {
const inputs = { prompt: 'Choose.', apiKey: 'test-api-key' }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('claude-sonnet-5')
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
model: 'claude-sonnet-5',
temperature: 0.1,
})
})
it('should handle server error responses', async () => {
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server error' }),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error')
})
it('should handle Azure OpenAI models with endpoint and API version', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
}
mockGetProviderFromModel.mockReturnValue('azure-openai')
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'azure-openai',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
})
})
it('should handle Vertex AI models with OAuth credential', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gemini-2.0-flash-exp',
vertexCredential: 'test-vertex-credential-id',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
}
mockGetProviderFromModel.mockReturnValue('vertex')
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000),
}
;(mockDb.db.query as any).account = { findFirst: vi.fn() }
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'vertex',
model: 'gemini-2.0-flash-exp',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
})
expect(requestBody.apiKey).toBe('mock-access-token')
})
})
describe('RouterBlockHandler V2', () => {
let handler: RouterBlockHandler
let mockRouterV2Block: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'agent', name: 'Support Agent' },
position: { x: 100, y: 100 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'agent', name: 'Sales Agent' },
position: { x: 100, y: 150 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockRouterV2Block = {
id: 'router-v2-block-1',
metadata: { id: BlockType.ROUTER_V2, name: 'Test Router V2' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER_V2, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockRouterV2Block, mockTargetBlock1, mockTargetBlock2],
connections: [
{
source: mockRouterV2Block.id,
target: mockTargetBlock1.id,
sourceHandle: 'router-route-support',
},
{
source: mockRouterV2Block.id,
target: mockTargetBlock2.id,
sourceHandle: 'router-route-sales',
},
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt')
})
it('should handle router_v2 blocks', () => {
expect(handler.canHandle(mockRouterV2Block)).toBe(true)
})
it('should execute router V2 and return reasoning', async () => {
const inputs = {
context: 'I need help with a billing issue',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([
{ id: 'route-support', title: 'Support', value: 'Customer support inquiries' },
{ id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' },
]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
}),
model: 'gpt-4o',
tokens: { input: 150, output: 25, total: 175 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result).toMatchObject({
context: 'I need help with a billing issue',
model: 'gpt-4o',
selectedRoute: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
selectedPath: {
blockId: 'target-block-1',
blockType: 'agent',
blockTitle: 'Support Agent',
},
})
})
it('should include responseFormat in provider request', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description 1' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await handler.execute(mockContext, mockRouterV2Block, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.responseFormat).toEqual({
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
})
})
it('should handle NO_MATCH response with reasoning', async () => {
const inputs = {
context: 'Random unrelated query',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Specific topic' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'NO_MATCH',
reasoning: 'The query does not relate to any available route.',
}),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'Router could not determine a matching route: The query does not relate to any available route.'
)
})
it('should throw error for invalid route ID in response', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
/Router could not determine a valid route/
)
})
it('should handle routes passed as array instead of JSON string', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: [{ id: 'route-1', title: 'Route 1', value: 'Description' }],
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('Matched route 1')
})
it('should throw error when no routes are defined', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: '[]',
}
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'No routes defined for router'
)
})
it('should handle fallback when JSON parsing fails', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'route-1',
model: 'gpt-4o',
tokens: { input: 100, output: 5, total: 105 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('')
})
})
@@ -0,0 +1,424 @@
import { createLogger } from '@sim/logger'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
isAgentBlockType,
isRouterV2BlockType,
ROUTER,
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/**
* Handler for Router blocks that dynamically select execution paths.
* Supports both legacy router (block-based) and router_v2 (port-based).
*/
export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const isV2 = isRouterV2BlockType(block.metadata?.id)
if (isV2) {
return this.executeV2(ctx, block, inputs)
}
return this.executeLegacy(ctx, block, inputs)
}
/**
* Execute legacy router (block-based routing).
*/
private async executeLegacy(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block)
const routerConfig = {
prompt: inputs.prompt,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.prompt }]
const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
const chosenBlockId = result.content.trim().toLowerCase()
const chosenBlock = targetBlocks?.find((b) => b.id === chosenBlockId)
if (!chosenBlock) {
logger.error(
`Invalid routing decision. Response content: "${result.content}", available blocks:`,
targetBlocks?.map((b) => ({ id: b.id, title: b.title })) || []
)
throw new Error(`Invalid routing decision: ${chosenBlockId}`)
}
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
prompt: inputs.prompt,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedPath: {
blockId: chosenBlock.id,
blockType: chosenBlock.type || DEFAULTS.BLOCK_TYPE,
blockTitle: chosenBlock.title || DEFAULTS.BLOCK_TITLE,
},
selectedRoute: String(chosenBlock.id),
} as BlockOutput
} catch (error) {
logger.error('Router execution failed:', error)
throw error
}
}
/**
* Execute router v2 (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
private async executeV2(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const routes = this.parseRoutes(inputs.routes)
if (routes.length === 0) {
throw new Error('No routes defined for router')
}
const routerConfig = {
context: inputs.context,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.context }]
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
responseFormat: {
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
},
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
let chosenRouteId: string
let reasoning = ''
try {
const parsedResponse = JSON.parse(result.content)
chosenRouteId = parsedResponse.route?.trim() || ''
reasoning = parsedResponse.reasoning || ''
} catch (_parseError) {
logger.error('Router response was not valid JSON despite responseFormat', {
content: result.content,
})
chosenRouteId = result.content.trim()
}
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
logger.info('Router determined no route matches the context, routing to error path')
throw new Error(
reasoning
? `Router could not determine a matching route: ${reasoning}`
: 'Router could not determine a matching route for the given context'
)
}
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error(
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
availableRoutes
)
throw new Error(
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
)
}
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
const targetBlock = connection
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
: null
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
context: inputs.context,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedRoute: chosenRoute.id,
reasoning,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
}
: {
blockId: '',
blockType: DEFAULTS.BLOCK_TYPE,
blockTitle: chosenRoute.title,
},
} as BlockOutput
} catch (error) {
logger.error('Router V2 execution failed:', error)
throw error
}
}
/**
* Parse routes from input (can be JSON string or array)
*/
private parseRoutes(input: any): RouteDefinition[] {
try {
if (typeof input === 'string') {
return JSON.parse(input)
}
if (Array.isArray(input)) {
return input
}
return []
} catch (error) {
logger.error('Failed to parse routes:', { input, error })
return []
}
}
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections
.filter((conn) => conn.source === block.id)
.map((conn) => {
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target)
if (!targetBlock) {
throw new Error(`Target block ${conn.target} not found`)
}
let systemPrompt = ''
if (isAgentBlockType(targetBlock.metadata?.id)) {
const paramsPrompt = targetBlock.config?.params?.systemPrompt
const inputsPrompt = targetBlock.inputs?.systemPrompt
systemPrompt =
(typeof paramsPrompt === 'string' ? paramsPrompt : '') ||
(typeof inputsPrompt === 'string' ? inputsPrompt : '') ||
''
}
return {
id: targetBlock.id,
type: targetBlock.metadata?.id,
title: targetBlock.metadata?.name,
description: targetBlock.metadata?.description,
subBlocks: {
...targetBlock.config.params,
systemPrompt: systemPrompt,
},
currentState: ctx.blockStates.get(targetBlock.id)?.output,
}
})
}
}