@@ -11,12 +11,14 @@ const {
1111 mockGetTool,
1212 mockGetCustomToolById,
1313 mockGetSkillById,
14+ mockGetHostedModels,
1415} = vi . hoisted ( ( ) => ( {
1516 mockValidateSelectorIds : vi . fn ( ) ,
1617 mockGetModelOptions : vi . fn ( ( ) => [ ] ) ,
1718 mockGetTool : vi . fn ( ) ,
1819 mockGetCustomToolById : vi . fn ( ) ,
1920 mockGetSkillById : vi . fn ( ) ,
21+ mockGetHostedModels : vi . fn ( ( ) => [ ] as string [ ] ) ,
2022} ) )
2123
2224const conditionBlockConfig = {
@@ -55,6 +57,18 @@ const agentBlockConfig = {
5557 ] ,
5658}
5759
60+ const piBlockConfig = {
61+ type : 'pi' ,
62+ name : 'Pi Coding Agent' ,
63+ outputs : { } ,
64+ subBlocks : [
65+ { id : 'mode' , type : 'dropdown' } ,
66+ { id : 'model' , type : 'combobox' , options : mockGetModelOptions } ,
67+ { id : 'apiKey' , type : 'short-input' } ,
68+ ] ,
69+ tools : { access : [ ] } ,
70+ }
71+
5872const huggingfaceBlockConfig = {
5973 type : 'huggingface' ,
6074 name : 'HuggingFace' ,
@@ -175,35 +189,25 @@ const toolsByIdMock: Record<string, unknown> = {
175189 } ,
176190}
177191
192+ const blockConfigsByType : Record < string , unknown > = {
193+ condition : conditionBlockConfig ,
194+ slack : oauthBlockConfig ,
195+ router_v2 : routerBlockConfig ,
196+ agent : agentBlockConfig ,
197+ pi : piBlockConfig ,
198+ huggingface : huggingfaceBlockConfig ,
199+ knowledge : knowledgeBlockConfig ,
200+ canonicalcred : canonicalCredBlockConfig ,
201+ video_generator_v3 : videoBlockConfig ,
202+ custom_key_block : customKeyBlockConfig ,
203+ image_generator_v2 : imageBlockConfig ,
204+ throw_gate_block : throwGateBlockConfig ,
205+ throw_selector_block : throwSelectorBlockConfig ,
206+ generic_webhook : genericWebhookBlockConfig ,
207+ }
208+
178209vi . mock ( '@/blocks/registry' , ( ) => ( {
179- getBlock : ( type : string ) =>
180- type === 'condition'
181- ? conditionBlockConfig
182- : type === 'slack'
183- ? oauthBlockConfig
184- : type === 'router_v2'
185- ? routerBlockConfig
186- : type === 'agent'
187- ? agentBlockConfig
188- : type === 'huggingface'
189- ? huggingfaceBlockConfig
190- : type === 'knowledge'
191- ? knowledgeBlockConfig
192- : type === 'canonicalcred'
193- ? canonicalCredBlockConfig
194- : type === 'video_generator_v3'
195- ? videoBlockConfig
196- : type === 'custom_key_block'
197- ? customKeyBlockConfig
198- : type === 'image_generator_v2'
199- ? imageBlockConfig
200- : type === 'throw_gate_block'
201- ? throwGateBlockConfig
202- : type === 'throw_selector_block'
203- ? throwSelectorBlockConfig
204- : type === 'generic_webhook'
205- ? genericWebhookBlockConfig
206- : undefined ,
210+ getBlock : ( type : string ) => blockConfigsByType [ type ] ,
207211} ) )
208212
209213vi . mock ( '@/blocks/utils' , ( ) => ( {
@@ -227,7 +231,7 @@ vi.mock('@/lib/workflows/skills/operations', () => ({
227231} ) )
228232
229233vi . mock ( '@/providers/utils' , ( ) => ( {
230- getHostedModels : ( ) => [ ] ,
234+ getHostedModels : mockGetHostedModels ,
231235} ) )
232236
233237import {
@@ -238,6 +242,8 @@ import {
238242 validateWorkflowSelectorIds ,
239243} from './validation'
240244
245+ const CTX = { userId : 'user-1' , workspaceId : 'workspace-1' }
246+
241247afterAll ( resetEnvFlagsMock )
242248
243249describe ( 'validateInputsForBlock' , ( ) => {
@@ -919,7 +925,69 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
919925 } )
920926} )
921927
922- const CTX = { userId : 'user-1' , workspaceId : 'workspace-1' }
928+ describe ( 'preValidateCredentialInputs (hosted models)' , ( ) => {
929+ beforeEach ( ( ) => {
930+ vi . clearAllMocks ( )
931+ mockValidateSelectorIds . mockResolvedValue ( { valid : [ ] , invalid : [ ] } )
932+ mockGetHostedModels . mockReturnValue ( [ 'claude-sonnet-4-6' ] )
933+ setEnvFlags ( { isHosted : true } )
934+ } )
935+
936+ afterEach ( ( ) => {
937+ mockGetHostedModels . mockReset ( )
938+ setEnvFlags ( { isHosted : false } )
939+ } )
940+
941+ const piAddOperation = ( mode : string ) => [
942+ {
943+ operation_type : 'add' as const ,
944+ block_id : 'pi-1' ,
945+ params : {
946+ type : 'pi' ,
947+ inputs : { mode, model : 'claude-sonnet-4-6' , apiKey : 'user-anthropic-key' } ,
948+ } ,
949+ } ,
950+ ]
951+
952+ it ( 'strips apiKey for a hosted model on a normal LLM block' , async ( ) => {
953+ const operations = [
954+ {
955+ operation_type : 'add' as const ,
956+ block_id : 'agent-1' ,
957+ params : {
958+ type : 'agent' ,
959+ inputs : { model : 'claude-sonnet-4-6' , apiKey : 'user-anthropic-key' } ,
960+ } ,
961+ } ,
962+ ]
963+
964+ const result = await preValidateCredentialInputs ( operations , CTX )
965+
966+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBeUndefined ( )
967+ expect ( result . errors ) . toHaveLength ( 1 )
968+ expect ( result . errors [ 0 ] ?. error ) . toContain ( 'hosted model' )
969+ } )
970+
971+ // Create PR hands the key to the sandbox, so Sim never covers it with a hosted
972+ // key -- stripping it would leave the copilot authoring a block that cannot run.
973+ it ( 'preserves apiKey on a Create PR Pi block when the model is hosted' , async ( ) => {
974+ const result = await preValidateCredentialInputs ( piAddOperation ( 'cloud' ) , CTX )
975+
976+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBe ( 'user-anthropic-key' )
977+ expect ( result . errors ) . toHaveLength ( 0 )
978+ } )
979+
980+ // Local Dev and Review Code keep the model client in Sim, so the hosted key applies.
981+ it . each ( [ [ 'local' ] , [ 'cloud_review' ] ] ) (
982+ 'strips apiKey on a Pi block in %s mode when the model is hosted' ,
983+ async ( mode ) => {
984+ const result = await preValidateCredentialInputs ( piAddOperation ( mode ) , CTX )
985+
986+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBeUndefined ( )
987+ expect ( result . errors ) . toHaveLength ( 1 )
988+ }
989+ )
990+ } )
923991
924992describe ( 'validateWorkflowSelectorIds (credential inclusion)' , ( ) => {
925993 beforeEach ( ( ) => {
0 commit comments