Skip to content

Commit de934b9

Browse files
feat(script): migrate agent/router/evaluator LLM keys to BYOK by model prefix (#5607)
* feat(byok): migrate agent/router/evaluator LLM keys to BYOK by model prefix * fix(script): address review — cover translate/guardrails, trim provider ids, reject mixed mapping, add key-prefix guard * fix(script): include pi block in LLM-family set, trim key-prefix value * fix(script): add router_v2 to LLM-family set, canonicalize provider ids to lowercase * fix(script): resolve overlapping model prefixes deterministically (longest match wins)
1 parent 8853b0c commit de934b9

1 file changed

Lines changed: 182 additions & 10 deletions

File tree

packages/db/scripts/migrate-block-api-keys-to-byok.ts

Lines changed: 182 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
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.
@@ -15,6 +34,10 @@
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

5578
const 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+
64191
function 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(
601758
async 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

Comments
 (0)