From fd1870cbff3ff21f4e2011f12540ce017a8d6361 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 14:59:13 -0700 Subject: [PATCH 1/4] feat(providers): add xAI to hosted key rotation pool Wires xai into the same hosted-key mechanism as OpenAI, Anthropic, and Z.ai so Sim can serve Grok models without users bringing their own key. --- apps/sim/lib/api-key/byok.ts | 3 ++- apps/sim/lib/api/contracts/byok-keys.ts | 1 + apps/sim/lib/core/config/api-keys.ts | 7 ++++++- apps/sim/lib/core/config/env.ts | 3 +++ apps/sim/lib/core/utils.test.ts | 8 ++++++++ apps/sim/providers/models.test.ts | 14 ++++++++++++++ apps/sim/providers/models.ts | 1 + apps/sim/providers/utils.test.ts | 8 +++++--- apps/sim/providers/utils.ts | 3 ++- apps/sim/tools/types.ts | 1 + 10 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 4a4f26b3271..6e45fe2cad8 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -205,13 +205,14 @@ export async function getApiKeyWithBYOK( const isGeminiModel = provider === 'google' const isMistralModel = provider === 'mistral' const isZaiModel = provider === 'zai' + const isXaiModel = provider === 'xai' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( isHosted && workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel) + (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d9b8a561099..80e7b075df1 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([ 'google', 'mistral', 'zai', + 'xai', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index 1a1f04218cd..b435da46f22 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -12,7 +12,8 @@ export function getRotatingApiKey(provider: string): string { provider !== 'anthropic' && provider !== 'gemini' && provider !== 'cohere' && - provider !== 'zai' + provider !== 'zai' && + provider !== 'xai' ) { throw new Error(`No rotation implemented for provider: ${provider}`) } @@ -39,6 +40,10 @@ export function getRotatingApiKey(provider: string): string { if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1) if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2) if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3) + } else if (provider === 'xai') { + if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1) + if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2) + if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3) } if (keys.length === 0) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 6cf92e243a3..0be542a4f4c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -147,6 +147,9 @@ export const env = createEnv({ ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing + XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing + XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing + XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible) VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index a9e2b1662f9..7ccb5dc75cf 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -43,6 +43,9 @@ vi.mock('@/lib/core/config/env', () => GEMINI_API_KEY_1: 'test-gemini-key-1', GEMINI_API_KEY_2: 'test-gemini-key-2', GEMINI_API_KEY_3: 'test-gemini-key-3', + XAI_API_KEY_1: 'test-xai-key-1', + XAI_API_KEY_2: 'test-xai-key-2', + XAI_API_KEY_3: 'test-xai-key-3', }) ) @@ -327,6 +330,11 @@ describe('getRotatingApiKey', () => { expect(result).toMatch(/^test-gemini-key-[1-3]$/) }) + it.concurrent('should return xAI API key based on current minute', () => { + const result = getRotatingApiKey('xai') + expect(result).toMatch(/^test-xai-key-[1-3]$/) + }) + it.concurrent('should throw error for unsupported provider', () => { expect(() => getRotatingApiKey('unsupported')).toThrow('No rotation implemented for provider') }) diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 0c01133649c..14dcee48590 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -218,3 +218,17 @@ describe('zai provider definition', () => { expect(getHostedModels()).toContain('glm-4.6') }) }) + +describe('xai provider definition', () => { + const xai = PROVIDER_DEFINITIONS.xai + + it('is registered with grok-4.5 as the default model', () => { + expect(xai).toBeDefined() + expect(xai.id).toBe('xai') + expect(xai.defaultModel).toBe('grok-4.5') + }) + + it('is included in getHostedModels since Sim provides the xAI key server-side', () => { + expect(getHostedModels()).toContain('grok-4.5') + }) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e5021a4c721..bc5aa24cbe7 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -3921,6 +3921,7 @@ export function getHostedModels(): string[] { ...getProviderModels('anthropic'), ...getProviderModels('google'), ...getProviderModels('zai'), + ...getProviderModels('xai'), ] } diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 1e9c877a976..1697c12f80d 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -865,7 +865,7 @@ describe('Cost Calculation', () => { }) describe('getHostedModels', () => { - it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => { + it.concurrent('should return OpenAI, Anthropic, Google, and xAI models as hosted', () => { const hostedModels = getHostedModels() expect(hostedModels).toContain('gpt-4o') @@ -877,8 +877,9 @@ describe('getHostedModels', () => { expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-flash') + expect(hostedModels).toContain('grok-4.5') + expect(hostedModels).not.toContain('deepseek-v3') - expect(hostedModels).not.toContain('grok-4-latest') }) it.concurrent('should return an array of strings', () => { @@ -902,11 +903,12 @@ describe('shouldBillModelUsage', () => { expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) + + expect(shouldBillModelUsage('grok-4.5')).toBe(true) }) it.concurrent('should return false for non-hosted models', () => { expect(shouldBillModelUsage('deepseek-v3')).toBe(false) - expect(shouldBillModelUsage('grok-4-latest')).toBe(false) expect(shouldBillModelUsage('unknown-model')).toBe(false) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 3b8bdd10c6a..8ca3017354a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -903,8 +903,9 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' const isZaiModel = provider === 'zai' + const isXaiModel = provider === 'xai' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel)) { + if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel)) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 910e825f251..cb3e7753a1d 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -8,6 +8,7 @@ export type BYOKProviderId = | 'google' | 'mistral' | 'zai' + | 'xai' | 'fireworks' | 'together' | 'baseten' From 91b4b1e5d70d071206531fc811f41ef851502484 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 15:06:49 -0700 Subject: [PATCH 2/4] fix(pi): include xai in Pi cloud-mode workspace BYOK read-back xai was fully wired as a Pi-supported provider but missing from WORKSPACE_BYOK_PROVIDERS, so a stored workspace xAI key was never read back for cloud-mode Pi runs. --- apps/sim/executor/handlers/pi/keys.test.ts | 15 +++++++++++++++ apps/sim/executor/handlers/pi/keys.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 17b407cc0ad..17c45a6d6ef 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -134,6 +134,21 @@ describe('resolvePiModelKey', () => { expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) + it('cloud mode falls back to a stored workspace key for xAI', async () => { + mockGetProviderFromModel.mockReturnValue('xai') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true }) + + const result = await resolvePiModelKey({ + model: 'grok-4.5', + mode: 'cloud', + workspaceId: 'ws-1', + }) + + expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true }) + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai') + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + it('cloud mode rejects when no user key is available (never a hosted key)', async () => { mockGetProviderFromModel.mockReturnValue('anthropic') mockGetBYOKKey.mockResolvedValue(null) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 9d85eb8a4ee..cc28d6de4b4 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -33,7 +33,13 @@ interface ResolvePiModelKeyParams { } /** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */ -const WORKSPACE_BYOK_PROVIDERS = new Set(['anthropic', 'openai', 'google', 'mistral']) +const WORKSPACE_BYOK_PROVIDERS = new Set([ + 'anthropic', + 'openai', + 'google', + 'mistral', + 'xai', +]) /** Resolves the provider and a usable API key for the selected model. */ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise { From dfc08689eb6e5d292a6c348dee2a555a95076bec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 15:16:37 -0700 Subject: [PATCH 3/4] fix(byok): add xai settings UI row xai is both hosted (Pi block hides its inline API key field for hosted models) and Pi-supported (cloud mode requires a user key), so without a Settings > BYOK row users had no way to supply an xai key for Pi cloud runs. --- .../[workspaceId]/settings/components/byok/byok.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index c365196abcd..d297cc49e3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -33,6 +33,7 @@ import { SerperIcon, TogetherIcon, WizaIcon, + xAIIcon, ZeroBounceIcon, } from '@/components/icons' import { MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys' @@ -75,6 +76,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls and Knowledge Base OCR', placeholder: 'Enter your API key', }, + { + id: 'xai', + name: 'xAI', + icon: xAIIcon, + description: 'LLM calls', + placeholder: 'xai-...', + }, { id: 'fireworks', name: 'Fireworks', @@ -279,6 +287,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'anthropic', 'google', 'mistral', + 'xai', 'fireworks', 'together', 'baseten', From 91fcf75d21b3ca9d71358b8ed8beaf3511878efd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 11 Jul 2026 11:28:17 -0700 Subject: [PATCH 4/4] feat(byok): migrate agent/router/evaluator LLM keys to BYOK by model prefix --- .../scripts/migrate-block-api-keys-to-byok.ts | 111 ++++++++++++++++-- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/db/scripts/migrate-block-api-keys-to-byok.ts b/packages/db/scripts/migrate-block-api-keys-to-byok.ts index 7f74d8ffbde..056094eb4bf 100644 --- a/packages/db/scripts/migrate-block-api-keys-to-byok.ts +++ b/packages/db/scripts/migrate-block-api-keys-to-byok.ts @@ -4,6 +4,19 @@ // Iterates per workspace. Original block-level values are left untouched for safety. // Handles both literal keys ("sk-xxx...") and env var references ("{{VAR_NAME}}"). // +// Two mapping modes: +// --map = Dedicated single-provider blocks. The block +// type unambiguously identifies the provider, so +// the block's `apiKey` subblock is taken directly +// (e.g. jina, perplexity). +// --model-map = +// LLM-family blocks (agent, router, evaluator). +// These share one model-gated `apiKey` subblock +// across every LLM provider, so the block type +// alone is NOT enough — the key is only taken when +// the block's selected `model` starts with the +// given prefix (e.g. grok -> xai). +// // Usage: // # Step 1 — Dry run: audit for conflicts + preview inserts (no DB writes) // # Outputs migrate-byok-workspace-ids.txt for the live run. @@ -15,6 +28,10 @@ // --map jina=jina --map perplexity=perplexity --map google_books=google_cloud \ // --from-file migrate-byok-workspace-ids.txt // +// # Migrate xAI (grok) keys entered into agent/router/evaluator blocks to BYOK +// bun run packages/db/scripts/migrate-block-api-keys-to-byok.ts --dry-run \ +// --model-map grok=xai +// // # Optionally scope dry run to specific users (repeatable) // bun run packages/db/scripts/migrate-block-api-keys-to-byok.ts --dry-run \ // --map jina=jina --user user_abc123 --user user_def456 @@ -53,14 +70,58 @@ function parseMapArgs(): Record { } const BLOCK_TYPE_TO_PROVIDER = parseMapArgs() -if (Object.keys(BLOCK_TYPE_TO_PROVIDER).length === 0) { - console.error('No --map arguments provided. Specify at least one: --map blockType=providerId') + +// LLM-family blocks share one model-gated `apiKey` subblock across every provider, so the +// key is only attributed to a provider when the block's selected `model` matches a prefix. +const LLM_FAMILY_BLOCK_TYPES = ['agent', 'router', 'evaluator'] as const + +function parseModelMapArgs(): Record { + const mapping: Record = {} + const args = process.argv.slice(2) + for (let i = 0; i < args.length; i++) { + if (args[i] === '--model-map' && args[i + 1]) { + const [modelPrefix, providerId] = args[i + 1].split('=') + if (modelPrefix && providerId) { + mapping[modelPrefix.trim().toLowerCase()] = providerId + } else { + console.error( + `Invalid --model-map value: "${args[i + 1]}". Expected format: modelPrefix=providerId` + ) + process.exit(1) + } + i++ + } + } + return mapping +} + +const MODEL_PREFIX_TO_PROVIDER = parseModelMapArgs() + +if ( + Object.keys(BLOCK_TYPE_TO_PROVIDER).length === 0 && + Object.keys(MODEL_PREFIX_TO_PROVIDER).length === 0 +) { + console.error('No --map or --model-map arguments provided. Specify at least one.') console.error( - 'Example: --map jina=jina --map perplexity=perplexity --map google_books=google_cloud' + 'Dedicated blocks: --map jina=jina --map perplexity=perplexity --map google_books=google_cloud' ) + console.error('LLM-family blocks (agent/router/evaluator): --model-map grok=xai') process.exit(1) } +/** + * Resolves an LLM-family block's selected model to a BYOK provider id via the configured + * model-prefix map. Mirrors how the app matches models to providers (e.g. grok -> xai). + */ +function resolveModelProvider(model: string): string | null { + const normalized = model.trim().toLowerCase() + if (!normalized) return null + for (const [prefix, providerId] of Object.entries(MODEL_PREFIX_TO_PROVIDER)) { + if (normalized.startsWith(prefix)) return providerId + } + return null +} + function parseUserArgs(): string[] { const users: string[] = [] const args = process.argv.slice(2) @@ -430,6 +491,27 @@ async function processWorkspace( } } + if ( + LLM_FAMILY_BLOCK_TYPES.includes(block.blockType as (typeof LLM_FAMILY_BLOCK_TYPES)[number]) + ) { + const model = subBlocks?.model?.value + const apiKeyVal = subBlocks?.apiKey?.value + if (typeof model === 'string' && typeof apiKeyVal === 'string' && apiKeyVal.trim()) { + const llmProviderId = resolveModelProvider(model) + if (llmProviderId) { + const refs = providerKeys.get(llmProviderId) ?? [] + refs.push({ + rawValue: apiKeyVal, + blockName: `${block.blockName} (model "${model}")`, + workflowId: block.workflowId, + workflowName: block.workflowName, + userId: block.userId, + }) + providerKeys.set(llmProviderId, refs) + } + } + } + const toolInputId = TOOL_INPUT_SUBBLOCK_IDS[block.blockType] if (toolInputId) { const tools = parseToolInputValue(subBlocks?.[toolInputId]?.value) @@ -601,9 +683,22 @@ async function processWorkspace( async function run() { console.log(`Mode: ${DRY_RUN ? 'DRY RUN (audit + preview)' : 'LIVE'}`) console.log( - `Mappings: ${Object.entries(BLOCK_TYPE_TO_PROVIDER) - .map(([b, p]) => `${b}=${p}`) - .join(', ')}` + `Block mappings: ${ + Object.keys(BLOCK_TYPE_TO_PROVIDER).length > 0 + ? Object.entries(BLOCK_TYPE_TO_PROVIDER) + .map(([b, p]) => `${b}=${p}`) + .join(', ') + : '(none)' + }` + ) + console.log( + `Model mappings (agent/router/evaluator): ${ + Object.keys(MODEL_PREFIX_TO_PROVIDER).length > 0 + ? Object.entries(MODEL_PREFIX_TO_PROVIDER) + .map(([m, p]) => `${m}*=${p}`) + .join(', ') + : '(none)' + }` ) console.log(`Users: ${USER_FILTER.length > 0 ? USER_FILTER.join(', ') : 'all'}`) if (FROM_FILE) console.log(`From file: ${FROM_FILE}`) @@ -616,7 +711,9 @@ async function run() { // 1. Get distinct workspace IDs that have matching blocks const mappedBlockTypes = Object.keys(BLOCK_TYPE_TO_PROVIDER) const agentTypes = Object.keys(TOOL_INPUT_SUBBLOCK_IDS) - const allBlockTypes = [...new Set([...mappedBlockTypes, ...agentTypes])] + const llmFamilyTypes = + Object.keys(MODEL_PREFIX_TO_PROVIDER).length > 0 ? [...LLM_FAMILY_BLOCK_TYPES] : [] + const allBlockTypes = [...new Set([...mappedBlockTypes, ...agentTypes, ...llmFamilyTypes])] const userFilter = USER_FILTER.length > 0