44// Iterates per workspace. Original block-level values are left untouched for safety.
55// Handles both literal keys ("sk-xxx...") and env var references ("{{VAR_NAME}}").
66//
7+ // Two mapping modes:
8+ // --map <blockType>=<providerId> Dedicated single-provider blocks. The block
9+ // type unambiguously identifies the provider, so
10+ // the block's `apiKey` subblock is taken directly
11+ // (e.g. jina, perplexity).
12+ // --model-map <modelPrefix>=<providerId>
13+ // LLM-family blocks (agent, router, evaluator,
14+ // translate, guardrails, pi, router_v2). These share one
15+ // `apiKey` subblock across every LLM provider, so the
16+ // block type alone is NOT enough — the key is only taken
17+ // when the block's selected `model` starts with the given
18+ // prefix (e.g. grok -> xai). --map may NOT target these
19+ // block types.
20+ // --require-key-prefix <providerId>=<prefix>
21+ // Optional guard: only migrate a key for <providerId>
22+ // when it starts with <prefix>. Filters stale keys left
23+ // in the shared apiKey field after a model switch
24+ // (e.g. --require-key-prefix xai=xai-).
25+ //
726// Usage:
827// # Step 1 — Dry run: audit for conflicts + preview inserts (no DB writes)
928// # Outputs migrate-byok-workspace-ids.txt for the live run.
1534// --map jina=jina --map perplexity=perplexity --map google_books=google_cloud \
1635// --from-file migrate-byok-workspace-ids.txt
1736//
37+ // # Migrate xAI (grok) keys entered into agent/router/evaluator blocks to BYOK
38+ // bun run packages/db/scripts/migrate-block-api-keys-to-byok.ts --dry-run \
39+ // --model-map grok=xai
40+ //
1841// # Optionally scope dry run to specific users (repeatable)
1942// bun run packages/db/scripts/migrate-block-api-keys-to-byok.ts --dry-run \
2043// --map jina=jina --user user_abc123 --user user_def456
@@ -38,8 +61,8 @@ function parseMapArgs(): Record<string, string> {
3861 for ( let i = 0 ; i < args . length ; i ++ ) {
3962 if ( args [ i ] === '--map' && args [ i + 1 ] ) {
4063 const [ blockType , providerId ] = args [ i + 1 ] . split ( '=' )
41- if ( blockType && providerId ) {
42- mapping [ blockType ] = providerId
64+ if ( blockType ?. trim ( ) && providerId ?. trim ( ) ) {
65+ mapping [ blockType . trim ( ) ] = providerId . trim ( ) . toLowerCase ( )
4366 } else {
4467 console . error (
4568 `Invalid --map value: "${ args [ i + 1 ] } ". Expected format: blockType=providerId`
@@ -53,14 +76,118 @@ function parseMapArgs(): Record<string, string> {
5376}
5477
5578const BLOCK_TYPE_TO_PROVIDER = parseMapArgs ( )
56- if ( Object . keys ( BLOCK_TYPE_TO_PROVIDER ) . length === 0 ) {
57- console . error ( 'No --map arguments provided. Specify at least one: --map blockType=providerId' )
79+
80+ // LLM-family blocks share one model-gated `apiKey` subblock (via getProviderCredentialSubBlocks)
81+ // across every provider, so the key is only attributed to a provider when the block's selected
82+ // `model` matches a prefix. This is every block whose model dropdown is getModelOptions(); the
83+ // deprecated `vision` block is excluded — its curated model list offers no grok models.
84+ const LLM_FAMILY_BLOCK_TYPES = [
85+ 'agent' ,
86+ 'router' ,
87+ 'router_v2' ,
88+ 'evaluator' ,
89+ 'translate' ,
90+ 'guardrails' ,
91+ 'pi' ,
92+ ] as const
93+
94+ function parseModelMapArgs ( ) : Record < string , string > {
95+ const mapping : Record < string , string > = { }
96+ const args = process . argv . slice ( 2 )
97+ for ( let i = 0 ; i < args . length ; i ++ ) {
98+ if ( args [ i ] === '--model-map' && args [ i + 1 ] ) {
99+ const [ modelPrefix , providerId ] = args [ i + 1 ] . split ( '=' )
100+ if ( modelPrefix ?. trim ( ) && providerId ?. trim ( ) ) {
101+ mapping [ modelPrefix . trim ( ) . toLowerCase ( ) ] = providerId . trim ( ) . toLowerCase ( )
102+ } else {
103+ console . error (
104+ `Invalid --model-map value: "${ args [ i + 1 ] } ". Expected format: modelPrefix=providerId`
105+ )
106+ process . exit ( 1 )
107+ }
108+ i ++
109+ }
110+ }
111+ return mapping
112+ }
113+
114+ const MODEL_PREFIX_TO_PROVIDER = parseModelMapArgs ( )
115+
116+ if (
117+ Object . keys ( BLOCK_TYPE_TO_PROVIDER ) . length === 0 &&
118+ Object . keys ( MODEL_PREFIX_TO_PROVIDER ) . length === 0
119+ ) {
120+ console . error ( 'No --map or --model-map arguments provided. Specify at least one.' )
121+ console . error (
122+ 'Dedicated blocks: --map jina=jina --map perplexity=perplexity --map google_books=google_cloud'
123+ )
124+ console . error ( `LLM-family blocks (${ LLM_FAMILY_BLOCK_TYPES . join ( '/' ) } ): --model-map grok=xai` )
125+ process . exit ( 1 )
126+ }
127+
128+ // LLM-family blocks must only be migrated through --model-map (model-gated). Allowing them in the
129+ // block-type map would attribute their shared apiKey to a single provider regardless of the model,
130+ // and — if both modes name the same block — insert the key under two providers. Reject it outright.
131+ const misusedLlmBlockMaps = Object . keys ( BLOCK_TYPE_TO_PROVIDER ) . filter ( ( blockType ) =>
132+ LLM_FAMILY_BLOCK_TYPES . includes ( blockType as ( typeof LLM_FAMILY_BLOCK_TYPES ) [ number ] )
133+ )
134+ if ( misusedLlmBlockMaps . length > 0 ) {
58135 console . error (
59- 'Example: --map jina=jina --map perplexity=perplexity --map google_books=google_cloud'
136+ `--map cannot target LLM-family blocks (${ misusedLlmBlockMaps . join ( ', ' ) } ); their apiKey is ` +
137+ 'shared across providers. Use --model-map <modelPrefix>=<providerId> so the key is attributed ' +
138+ 'by the selected model (e.g. --model-map grok=xai).'
60139 )
61140 process . exit ( 1 )
62141}
63142
143+ // Optional per-provider key-shape guard. The LLM-family `apiKey` subblock is shared across
144+ // providers and retains stale values after a model switch, so a leftover key from a prior
145+ // provider can match the model prefix and be migrated under the wrong provider. When a required
146+ // prefix is configured for a provider, only keys starting with it are migrated (e.g. xai=xai-).
147+ function parseRequireKeyPrefixArgs ( ) : Record < string , string > {
148+ const mapping : Record < string , string > = { }
149+ const args = process . argv . slice ( 2 )
150+ for ( let i = 0 ; i < args . length ; i ++ ) {
151+ if ( args [ i ] === '--require-key-prefix' && args [ i + 1 ] ) {
152+ const idx = args [ i + 1 ] . indexOf ( '=' )
153+ const providerId = idx >= 0 ? args [ i + 1 ] . slice ( 0 , idx ) . trim ( ) : ''
154+ const prefix = idx >= 0 ? args [ i + 1 ] . slice ( idx + 1 ) . trim ( ) : ''
155+ if ( providerId && prefix ) {
156+ mapping [ providerId . toLowerCase ( ) ] = prefix
157+ } else {
158+ console . error (
159+ `Invalid --require-key-prefix value: "${ args [ i + 1 ] } ". Expected format: providerId=prefix`
160+ )
161+ process . exit ( 1 )
162+ }
163+ i ++
164+ }
165+ }
166+ return mapping
167+ }
168+
169+ const REQUIRED_KEY_PREFIX = parseRequireKeyPrefixArgs ( )
170+
171+ // Longest prefix first so the most specific mapping wins when prefixes overlap
172+ // (e.g. grok-4=other beats grok=xai for "grok-4-fast"), making resolution deterministic
173+ // regardless of --model-map argument order.
174+ const MODEL_PREFIX_ENTRIES = Object . entries ( MODEL_PREFIX_TO_PROVIDER ) . sort (
175+ ( a , b ) => b [ 0 ] . length - a [ 0 ] . length
176+ )
177+
178+ /**
179+ * Resolves an LLM-family block's selected model to a BYOK provider id via the configured
180+ * model-prefix map. Mirrors how the app matches models to providers (e.g. grok -> xai).
181+ */
182+ function resolveModelProvider ( model : string ) : string | null {
183+ const normalized = model . trim ( ) . toLowerCase ( )
184+ if ( ! normalized ) return null
185+ for ( const [ prefix , providerId ] of MODEL_PREFIX_ENTRIES ) {
186+ if ( normalized . startsWith ( prefix ) ) return providerId
187+ }
188+ return null
189+ }
190+
64191function parseUserArgs ( ) : string [ ] {
65192 const users : string [ ] = [ ]
66193 const args = process . argv . slice ( 2 )
@@ -430,6 +557,27 @@ async function processWorkspace(
430557 }
431558 }
432559
560+ if (
561+ LLM_FAMILY_BLOCK_TYPES . includes ( block . blockType as ( typeof LLM_FAMILY_BLOCK_TYPES ) [ number ] )
562+ ) {
563+ const model = subBlocks ?. model ?. value
564+ const apiKeyVal = subBlocks ?. apiKey ?. value
565+ if ( typeof model === 'string' && typeof apiKeyVal === 'string' && apiKeyVal . trim ( ) ) {
566+ const llmProviderId = resolveModelProvider ( model )
567+ if ( llmProviderId ) {
568+ const refs = providerKeys . get ( llmProviderId ) ?? [ ]
569+ refs . push ( {
570+ rawValue : apiKeyVal ,
571+ blockName : `${ block . blockName } (model "${ model } ")` ,
572+ workflowId : block . workflowId ,
573+ workflowName : block . workflowName ,
574+ userId : block . userId ,
575+ } )
576+ providerKeys . set ( llmProviderId , refs )
577+ }
578+ }
579+ }
580+
433581 const toolInputId = TOOL_INPUT_SUBBLOCK_IDS [ block . blockType ]
434582 if ( toolInputId ) {
435583 const tools = parseToolInputValue ( subBlocks ?. [ toolInputId ] ?. value )
@@ -522,7 +670,16 @@ async function processWorkspace(
522670 continue
523671 }
524672
525- resolved . push ( { ref, key : key . trim ( ) , source } )
673+ const requiredPrefix = REQUIRED_KEY_PREFIX [ providerId ]
674+ const trimmedKey = key . trim ( )
675+ if ( requiredPrefix && ! trimmedKey . startsWith ( requiredPrefix ) ) {
676+ console . log (
677+ ` [SKIP-PREFIX] Key for provider "${ providerId } " does not start with "${ requiredPrefix } " (likely a stale key from another provider) — workflow=${ ref . workflowId } "${ ref . blockName } " in "${ ref . workflowName } "`
678+ )
679+ continue
680+ }
681+
682+ resolved . push ( { ref, key : trimmedKey , source } )
526683 }
527684
528685 if ( resolved . length === 0 ) continue
@@ -601,9 +758,22 @@ async function processWorkspace(
601758async function run ( ) {
602759 console . log ( `Mode: ${ DRY_RUN ? 'DRY RUN (audit + preview)' : 'LIVE' } ` )
603760 console . log (
604- `Mappings: ${ Object . entries ( BLOCK_TYPE_TO_PROVIDER )
605- . map ( ( [ b , p ] ) => `${ b } =${ p } ` )
606- . join ( ', ' ) } `
761+ `Block mappings: ${
762+ Object . keys ( BLOCK_TYPE_TO_PROVIDER ) . length > 0
763+ ? Object . entries ( BLOCK_TYPE_TO_PROVIDER )
764+ . map ( ( [ b , p ] ) => `${ b } =${ p } ` )
765+ . join ( ', ' )
766+ : '(none)'
767+ } `
768+ )
769+ console . log (
770+ `Model mappings (agent/router/evaluator): ${
771+ Object . keys ( MODEL_PREFIX_TO_PROVIDER ) . length > 0
772+ ? Object . entries ( MODEL_PREFIX_TO_PROVIDER )
773+ . map ( ( [ m , p ] ) => `${ m } *=${ p } ` )
774+ . join ( ', ' )
775+ : '(none)'
776+ } `
607777 )
608778 console . log ( `Users: ${ USER_FILTER . length > 0 ? USER_FILTER . join ( ', ' ) : 'all' } ` )
609779 if ( FROM_FILE ) console . log ( `From file: ${ FROM_FILE } ` )
@@ -616,7 +786,9 @@ async function run() {
616786 // 1. Get distinct workspace IDs that have matching blocks
617787 const mappedBlockTypes = Object . keys ( BLOCK_TYPE_TO_PROVIDER )
618788 const agentTypes = Object . keys ( TOOL_INPUT_SUBBLOCK_IDS )
619- const allBlockTypes = [ ...new Set ( [ ...mappedBlockTypes , ...agentTypes ] ) ]
789+ const llmFamilyTypes =
790+ Object . keys ( MODEL_PREFIX_TO_PROVIDER ) . length > 0 ? [ ...LLM_FAMILY_BLOCK_TYPES ] : [ ]
791+ const allBlockTypes = [ ...new Set ( [ ...mappedBlockTypes , ...agentTypes , ...llmFamilyTypes ] ) ]
620792
621793 const userFilter =
622794 USER_FILTER . length > 0
0 commit comments