Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -279,6 +287,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [
'anthropic',
'google',
'mistral',
'xai',
'fireworks',
'together',
'baseten',
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/executor/handlers/pi/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/executor/handlers/pi/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(['anthropic', 'openai', 'google', 'mistral'])
const WORKSPACE_BYOK_PROVIDERS = new Set<string>([
'anthropic',
'openai',
'google',
'mistral',
'xai',
])

/** Resolves the provider and a usable API key for the selected model. */
export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise<PiKeyResolution> {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/byok-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([
'google',
'mistral',
'zai',
'xai',
'fireworks',
'together',
'baseten',
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/core/config/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/core/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
)

Expand Down Expand Up @@ -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')
})
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/providers/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
1 change: 1 addition & 0 deletions apps/sim/providers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3921,6 +3921,7 @@ export function getHostedModels(): string[] {
...getProviderModels('anthropic'),
...getProviderModels('google'),
...getProviderModels('zai'),
...getProviderModels('xai'),
]
}

Expand Down
8 changes: 5 additions & 3 deletions apps/sim/providers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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', () => {
Expand All @@ -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)
})
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/providers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
1 change: 1 addition & 0 deletions apps/sim/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type BYOKProviderId =
| 'google'
| 'mistral'
| 'zai'
| 'xai'
| 'fireworks'
| 'together'
| 'baseten'
Expand Down
111 changes: 104 additions & 7 deletions packages/db/scripts/migrate-block-api-keys-to-byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <blockType>=<providerId> 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 <modelPrefix>=<providerId>
// 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.
Expand All @@ -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
Expand Down Expand Up @@ -53,14 +70,58 @@ function parseMapArgs(): Record<string, string> {
}

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<string, string> {
const mapping: Record<string, string> = {}
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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}`)
Expand All @@ -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
Expand Down
Loading