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
29 changes: 26 additions & 3 deletions apps/sim/blocks/blocks/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import {
getApiKeyCondition,
getPiModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import { isPiByokOnlyMode } from '@/providers/pi-providers'
import type { ToolResponse } from '@/tools/types'

interface PiResponse extends ToolResponse {
Expand Down Expand Up @@ -79,6 +81,20 @@ function getSearchApiKeyCondition() {
}
}

const hostedModelApiKeyCondition = getApiKeyCondition()

/**
* API Key visibility for the Pi block.
*
* Create PR hands the model key to the sandbox as an environment variable, so
* Sim never supplies a hosted key there — the field is shown for every model,
* including ones that are hosted elsewhere in Sim. Review Code and Local Dev
* keep the model client inside Sim, so they follow the standard hosted-model
* rule and hide the field when Sim covers the key.
*/
const piApiKeyCondition = (values?: Record<string, unknown>) =>
isPiByokOnlyMode(values?.mode) ? CLOUD : hostedModelApiKeyCondition(values)

export const PiBlock: BlockConfig<PiResponse> = {
type: 'pi',
name: 'Pi Coding Agent',
Expand All @@ -90,7 +106,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
- Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
- Use Review Code to analyze an existing PR and leave summary + inline review comments.
- Use Local Dev to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH.
- Create PR requires your own provider API key because the model runs in the sandbox. Review Code keeps the model key in Sim and can use either BYOK or a hosted key.
- Create PR requires your own provider API key for every model, including ones Sim hosts, because the model runs in the sandbox. Review Code and Local Dev keep the model key in Sim and can use either BYOK or a hosted key.
- Internet Search is off by default and always needs your own key for the selected provider, entered on the block. There is no workspace BYOK fallback and no hosted key. Leave it on None unless the task genuinely needs external information.
`,
category: 'blocks',
Expand Down Expand Up @@ -146,8 +162,15 @@ export const PiBlock: BlockConfig<PiResponse> = {
options: getPiModelOptions,
commandSearchable: true,
},

...getProviderCredentialSubBlocks(),
...getProviderCredentialSubBlocks().map((subBlock) =>
subBlock.id === 'apiKey'
? {
...subBlock,
placeholder: 'Enter your API key for the selected model',
condition: piApiKeyCondition,
}
: subBlock
),

{
id: 'searchProvider',
Expand Down
58 changes: 58 additions & 0 deletions apps/sim/blocks/pi-api-key-condition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @vitest-environment node
*/
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
import { PiBlock } from '@/blocks/blocks/pi'
import { getHostedModels } from '@/providers/utils'

const apiKeySubBlock = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'apiKey')
const hostedModel = getHostedModels()[0]

function isApiKeyVisible(values: Record<string, unknown>): boolean {
return evaluateSubBlockCondition(apiKeySubBlock?.condition, values)
}

describe('Pi API Key visibility', () => {
beforeEach(() => {
setEnvFlags({ isHosted: true })
})

afterEach(() => {
setEnvFlags({ isHosted: false })
})

afterAll(resetEnvFlagsMock)

it('exposes an apiKey subblock that is required when visible', () => {
expect(apiKeySubBlock).toBeDefined()
expect(apiKeySubBlock?.required).toBe(true)
})

// The bug this guards: the field used to hide for hosted models in every mode,
// and Create PR then failed at execution demanding a BYOK key the user was
// never asked for.
it('shows the field in Create PR even for a model Sim hosts', () => {
expect(hostedModel).toBeDefined()
expect(isApiKeyVisible({ mode: 'cloud', model: hostedModel })).toBe(true)
})

it('shows the field in Create PR for a model Sim does not host', () => {
expect(isApiKeyVisible({ mode: 'cloud', model: 'some-unhosted-model' })).toBe(true)
})

it.each([['local'], ['cloud_review']])(
'hides the field in %s mode for a model Sim hosts',
(mode) => {
expect(isApiKeyVisible({ mode, model: hostedModel })).toBe(false)
}
)

it.each([['local'], ['cloud_review']])(
'shows the field in %s mode for a model Sim does not host',
(mode) => {
expect(isApiKeyVisible({ mode, model: 'some-unhosted-model' })).toBe(true)
}
)
})
3 changes: 2 additions & 1 deletion apps/sim/executor/handlers/pi/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { PiSupportedProvider } from '@/providers/pi-provider-configs'
import {
getPiProviderApiKeyEnvVar,
getPiWorkspaceBYOKProviderId,
isPiByokOnlyMode,
isPiSupportedProvider,
} from '@/providers/pi-providers'

Expand Down Expand Up @@ -45,7 +46,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis
return { apiKey: params.apiKey, isBYOK: true }
}

if (params.mode === 'cloud') {
if (isPiByokOnlyMode(params.mode)) {
const workspaceBYOKProviderId = getPiWorkspaceBYOKProviderId(providerId)
if (params.workspaceId && workspaceBYOKProviderId) {
const byok = await getBYOKKey(params.workspaceId, workspaceBYOKProviderId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ const {
mockGetTool,
mockGetCustomToolById,
mockGetSkillById,
mockGetHostedModels,
} = vi.hoisted(() => ({
mockValidateSelectorIds: vi.fn(),
mockGetModelOptions: vi.fn(() => []),
mockGetTool: vi.fn(),
mockGetCustomToolById: vi.fn(),
mockGetSkillById: vi.fn(),
mockGetHostedModels: vi.fn(() => [] as string[]),
}))

const conditionBlockConfig = {
Expand Down Expand Up @@ -55,6 +57,18 @@ const agentBlockConfig = {
],
}

const piBlockConfig = {
type: 'pi',
name: 'Pi Coding Agent',
outputs: {},
subBlocks: [
{ id: 'mode', type: 'dropdown' },
{ id: 'model', type: 'combobox', options: mockGetModelOptions },
{ id: 'apiKey', type: 'short-input' },
],
tools: { access: [] },
}

const huggingfaceBlockConfig = {
type: 'huggingface',
name: 'HuggingFace',
Expand Down Expand Up @@ -175,35 +189,25 @@ const toolsByIdMock: Record<string, unknown> = {
},
}

const blockConfigsByType: Record<string, unknown> = {
condition: conditionBlockConfig,
slack: oauthBlockConfig,
router_v2: routerBlockConfig,
agent: agentBlockConfig,
pi: piBlockConfig,
huggingface: huggingfaceBlockConfig,
knowledge: knowledgeBlockConfig,
canonicalcred: canonicalCredBlockConfig,
video_generator_v3: videoBlockConfig,
custom_key_block: customKeyBlockConfig,
image_generator_v2: imageBlockConfig,
throw_gate_block: throwGateBlockConfig,
throw_selector_block: throwSelectorBlockConfig,
generic_webhook: genericWebhookBlockConfig,
}

vi.mock('@/blocks/registry', () => ({
getBlock: (type: string) =>
type === 'condition'
? conditionBlockConfig
: type === 'slack'
? oauthBlockConfig
: type === 'router_v2'
? routerBlockConfig
: type === 'agent'
? agentBlockConfig
: type === 'huggingface'
? huggingfaceBlockConfig
: type === 'knowledge'
? knowledgeBlockConfig
: type === 'canonicalcred'
? canonicalCredBlockConfig
: type === 'video_generator_v3'
? videoBlockConfig
: type === 'custom_key_block'
? customKeyBlockConfig
: type === 'image_generator_v2'
? imageBlockConfig
: type === 'throw_gate_block'
? throwGateBlockConfig
: type === 'throw_selector_block'
? throwSelectorBlockConfig
: type === 'generic_webhook'
? genericWebhookBlockConfig
: undefined,
getBlock: (type: string) => blockConfigsByType[type],
}))

vi.mock('@/blocks/utils', () => ({
Expand All @@ -227,7 +231,7 @@ vi.mock('@/lib/workflows/skills/operations', () => ({
}))

vi.mock('@/providers/utils', () => ({
getHostedModels: () => [],
getHostedModels: mockGetHostedModels,
}))

import {
Expand All @@ -238,6 +242,8 @@ import {
validateWorkflowSelectorIds,
} from './validation'

const CTX = { userId: 'user-1', workspaceId: 'workspace-1' }

afterAll(resetEnvFlagsMock)

describe('validateInputsForBlock', () => {
Expand Down Expand Up @@ -919,7 +925,69 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
})
})

const CTX = { userId: 'user-1', workspaceId: 'workspace-1' }
describe('preValidateCredentialInputs (hosted models)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] })
mockGetHostedModels.mockReturnValue(['claude-sonnet-4-6'])
setEnvFlags({ isHosted: true })
})

afterEach(() => {
mockGetHostedModels.mockReset()
setEnvFlags({ isHosted: false })
})

const piAddOperation = (mode: string) => [
{
operation_type: 'add' as const,
block_id: 'pi-1',
params: {
type: 'pi',
inputs: { mode, model: 'claude-sonnet-4-6', apiKey: 'user-anthropic-key' },
},
},
]

it('strips apiKey for a hosted model on a normal LLM block', async () => {
const operations = [
{
operation_type: 'add' as const,
block_id: 'agent-1',
params: {
type: 'agent',
inputs: { model: 'claude-sonnet-4-6', apiKey: 'user-anthropic-key' },
},
},
]

const result = await preValidateCredentialInputs(operations, CTX)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]?.error).toContain('hosted model')
})

// Create PR hands the key to the sandbox, so Sim never covers it with a hosted
// key -- stripping it would leave the copilot authoring a block that cannot run.
it('preserves apiKey on a Create PR Pi block when the model is hosted', async () => {
const result = await preValidateCredentialInputs(piAddOperation('cloud'), CTX)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('user-anthropic-key')
expect(result.errors).toHaveLength(0)
})

// Local Dev and Review Code keep the model client in Sim, so the hosted key applies.
it.each([['local'], ['cloud_review']])(
'strips apiKey on a Pi block in %s mode when the model is hosted',
async (mode) => {
const result = await preValidateCredentialInputs(piAddOperation(mode), CTX)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
}
)
})

describe('validateWorkflowSelectorIds (credential inclusion)', () => {
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getModelOptions } from '@/blocks/utils'
import { EDGE, normalizeName } from '@/executor/constants'
import { BlockType, EDGE, normalizeName } from '@/executor/constants'
import { isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
import { isPiByokOnlyMode } from '@/providers/pi-providers'
import { getTool } from '@/tools/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import type {
Expand Down Expand Up @@ -1248,7 +1249,8 @@ export async function collectUnresolvedAgentToolReferences(
* - Filters out apiKey inputs when isHosted is true and the key is platform-managed: either a
* hosted LLM model (model in getHostedModels) or a block whose active tool declares
* `hosting` (e.g. Fal-backed video/image generators) - the canonical signal also used by
* injectHostedKeyIfNeeded at execution
* injectHostedKeyIfNeeded at execution. The Pi Coding Agent block in Create PR mode is
* exempt from the hosted-model rule because it always runs on the user's own key
* - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel)
* Returns validation errors for any removed inputs.
*/
Expand Down Expand Up @@ -1316,17 +1318,24 @@ export async function preValidateCredentialInputs(
}

/**
* Check if apiKey should be filtered for a block with the given model
* Check if apiKey should be filtered for a block with the given model.
*
* The Pi Coding Agent in Create PR mode is exempt: it hands the model key to
* a sandbox, so Sim never covers it with a hosted key and the block needs the
* user's own key even for hosted models. Its other modes keep the model
* client in Sim and follow the normal rule.
*/
function collectHostedApiKeyInput(
inputs: Record<string, unknown>,
modelValue: string | undefined,
toolParams: Record<string, unknown>,
opIndex: number,
blockId: string,
blockType: string,
nestedBlockId?: string
) {
if (!hostedModelsLower || !inputs.apiKey) return
if (blockType === BlockType.PI && isPiByokOnlyMode(toolParams.mode)) return
const modelValue = toolParams.model
if (!modelValue || typeof modelValue !== 'string') return

if (hostedModelsLower.has(modelValue.toLowerCase())) {
Expand Down Expand Up @@ -1525,10 +1534,9 @@ export async function preValidateCredentialInputs(
// Hosted collectors no-op off hosted Sim, so only resolve the effective state when it matters.
if (isHosted) {
const toolParams = finalValues.get(stateKey) ?? inputs
const modelValue = toolParams.model as string | undefined
collectHostedApiKeyInput(
inputs,
modelValue,
toolParams,
opIndex,
reportBlockId,
blockType,
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/providers/pi-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ export function isPiSupportedProvider(providerId: string): providerId is PiSuppo
return PI_PROVIDER_CONFIG_BY_ID.has(providerId)
}

/**
* Whether a Pi block mode hands the model API key into the sandbox and
* therefore always requires the user's own key. Create PR ('cloud') runs the
* model client inside the sandbox, so Sim never supplies a hosted key for it:
* the block always shows the API Key field, copilot validation never strips
* it, and execution requires BYOK. Review Code and Local Dev keep the model
* client in Sim and follow the normal hosted-key rules. All three enforcement
* sites (block condition, edit-workflow validation, key resolution) consume
* this predicate so they cannot drift.
*/
export function isPiByokOnlyMode(mode: unknown): boolean {
return mode === 'cloud'
}

/** Returns Pi's provider ID for a supported Sim provider. */
export function getPiProviderId(providerId: PiSupportedProvider): PiProviderConfig['piProviderId'] {
const config = PI_PROVIDER_CONFIG_BY_ID.get(providerId)
Expand Down
Loading