type KnownProvider =
| 'openai' | 'anthropic' | 'google' | 'xai' | 'mistral' | 'cohere'
| 'deepseek' | 'moonshotai' | 'groq' | 'togetherai' | 'fireworks'
| 'perplexity' | 'cerebras' | 'deepinfra';
const PROVIDER_TABLE: Record<KnownProvider, {
envVar: string;
prefix: string; // matches OpenRouter's own model-id namespacing for that vendor
load: (modelId: string) => Promise<LanguageModel>;
}> = {
openai: { envVar: 'OPENAI_API_KEY', prefix: 'openai/', load: async id => (await import('@ai-sdk/openai')).createOpenAI()(id) },
anthropic: { envVar: 'ANTHROPIC_API_KEY', prefix: 'anthropic/', load: async id => (await import('@ai-sdk/anthropic')).createAnthropic()(id) },
google: { envVar: 'GOOGLE_GENERATIVE_AI_API_KEY', prefix: 'google/', load: async id => (await import('@ai-sdk/google')).createGoogleGenerativeAI()(id) },
xai: { envVar: 'XAI_API_KEY', prefix: 'xai/', load: async id => (await import('@ai-sdk/xai')).createXai()(id) },
mistral: { envVar: 'MISTRAL_API_KEY', prefix: 'mistral/', load: async id => (await import('@ai-sdk/mistral')).createMistral()(id) },
cohere: { envVar: 'COHERE_API_KEY', prefix: 'cohere/', load: async id => (await import('@ai-sdk/cohere')).createCohere()(id) },
deepseek: { envVar: 'DEEPSEEK_API_KEY', prefix: 'deepseek/', load: async id => (await import('@ai-sdk/deepseek')).createDeepSeek()(id) },
moonshotai: { envVar: 'MOONSHOT_API_KEY', prefix: 'moonshotai/', load: async id => (await import('@ai-sdk/moonshotai')).createMoonshot()(id) },
groq: { envVar: 'GROQ_API_KEY', prefix: 'groq/', load: async id => (await import('@ai-sdk/groq')).createGroq()(id) },
togetherai: { envVar: 'TOGETHER_API_KEY', prefix: 'togetherai/', load: async id => (await import('@ai-sdk/togetherai')).createTogetherAI()(id) },
fireworks: { envVar: 'FIREWORKS_API_KEY', prefix: 'fireworks/', load: async id => (await import('@ai-sdk/fireworks')).createFireworks()(id) },
perplexity: { envVar: 'PERPLEXITY_API_KEY', prefix: 'perplexity/', load: async id => (await import('@ai-sdk/perplexity')).createPerplexity()(id) },
cerebras: { envVar: 'CEREBRAS_API_KEY', prefix: 'cerebras/', load: async id => (await import('@ai-sdk/cerebras')).createCerebras()(id) },
deepinfra: { envVar: 'DEEPINFRA_API_KEY', prefix: 'deepinfra/', load: async id => (await import('@ai-sdk/deepinfra')).createDeepInfra()(id) },
};
function inferAutoProvider(modelId: string): KnownProvider | 'openrouter' {
for (const [id, entry] of Object.entries(PROVIDER_TABLE) as [KnownProvider, typeof PROVIDER_TABLE[KnownProvider]][]) {
if (modelId.startsWith(entry.prefix) && process.env[entry.envVar]) return id;
}
return 'openrouter'; // serves nearly all the same models under one key
}
function stripPrefix(modelId: string, prefix: string): string {
return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
}
export async function resolveModel(
modelId: string,
provider: string, // 'auto' | 'openrouter' | KnownProvider | free-text for a future/custom provider
openrouter: OpenRouterClient,
) {
const resolved = provider === 'auto' ? inferAutoProvider(modelId) : provider;
if (resolved === 'openrouter') return openrouter.chat(modelId);
const entry = PROVIDER_TABLE[resolved as KnownProvider];
if (!entry) {
throw new Error(`Unknown provider "${provider}" — no client registered for it yet`);
}
if (!process.env[entry.envVar]) {
throw new Error(`provider: '${resolved}' requires ${entry.envVar} to be set`);
}
return entry.load(stripPrefix(modelId, entry.prefix));
}
Every provider package is dynamically imported only on the branch that needs it, so a deployment using only OpenRouter never pulls in the other thirteen. A free-text
Idea
The
ai-studio/ai-agentnode currently has a single, worker-wide model configuration:apps/execution-worker/src/engines/temporal/worker.tsbuilds one OpenRouter client and one model (env.AI_MODEL) at process startup, and everyai-agentnode in every workflow uses it. There is no way to pick a different model per node, per workflow, or to point a node at a different provider.This request is to make the model and provider configurable per agent node, with a sensible fallback chain down to today's worker-wide default, without expanding the required/documented env-var surface, and without any one provider (including OpenRouter) getting privileged/default status in the design.
Decisions
Model becomes a per-node config field, not just a worker-wide constant.
modelstring field to theai-agentnode's schema/uischema (apps/ai-studio/src/nodes/ai-agent/{schema,uischema}.ts) and toAiAgentNodeConfig(apps/execution-worker/src/domain/ai-studio-nodes.ts).Fallback chain, resolved per node execution, not once at boot:
Same shape for provider:
This mirrors the pattern already used for
apps/execution-worker's per-workflow data (e.g.globalVariablesinpackages/sdk/src/store/slices/diagram-slice.ts), so a workflow-level default lives in the diagram JSON via the existing "Workflow Settings" modal (packages/sdk/src/features/variables/modals/), not a new backend concept.provideris a flat, explicit field — every AI SDK provider with a documented, standard API-key env var is an equal, named option, none privileged as the implicit default path.routeVia: 'auto' | 'openrouter' | 'direct'enum, where'direct'bundled OpenAI and Anthropic together and OpenRouter silently absorbed "everything else." That's too opinionated: it treats OpenRouter as the default umbrella provider and OpenAI/Anthropic as a single undifferentiated escape hatch, when a user may deliberately want any of them explicitly.provideris a single field listing every AI SDK provider we have direct evidence for (a documented@ai-sdk/<x>package whose factory reads one flat, conventional API-key env var with no explicitapiKeypassed — confirmed against https://ai-sdk.dev/providers), plus'auto'as just one more option (not a wrapper around the others):Boolean(process.env.X)" pattern this design relies on — adding them is a separate follow-up with its own credential-presence logic, not acasebranch like the others.Select) populated from this list, so the common case is a couple of clicks. The field also accepts free text (or an "other…" option revealing a text input) for a provider added later, or a custom OpenAI-compatible endpoint, without requiring a schema change to unlock it. This keeps the dropdown as the primary, discoverable UX while not hard-locking the field to only the providers wired today.'auto'keeps today's zero-config default behavior: infer from the model-id prefix (openai/…,deepseek/…, etc.) when a matching API key is present, otherwise fall back to OpenRouter, which can serve nearly all of the same underlying models under one key. Choosing'auto'is an explicit, visible choice in the dropdown like any other value — not an invisible default state that happens whenever the user hasn't thought about it.One uniform dispatch mechanism for every provider — all provider-specific behavior lives inside Vercel's own
@ai-sdk/<x>packages, never in our code.resolveModel()treats every table entry identically: look up a factory, call it, get back aLanguageModel, hand it to the samegenerateText()call the executor already makes. Auth, request/response translation, and streaming quirks are entirely@ai-sdk/<x>'s concern — our code never branches on provider behavior beyond "which factory to call."@ai-sdk/openai'screateOpenAI()and@ai-sdk/anthropic'screateAnthropic()(and the other twelve factories) all auto-read their own conventionally-named env var when constructed with no explicitapiKey— the same convention the official provider SDKs use elsewhere.apps/execution-worker/src/env.ts— we only do aBoolean(process.env.<X>_API_KEY)-style presence check to decide whether a given provider selection is actually usable. The key value itself is never read, stored, or logged by our code; each SDK reads it directly.OPENROUTER_API_KEY,AI_MODEL) completely unchanged, while letting deployments that already export these standard vars (for other tooling, or intentionally) unlock direct-provider routing. Everyone else still has explicit'openrouter'available for any model OpenRouter serves.Implementation plan
Node config / UI
apps/ai-studio/src/nodes/ai-agent/schema.ts— add optionalmodel: stringand optionalprovider: string(validated against the known-provider list plus free text at the UI layer; kept as a plain string in the schema so a new provider value doesn't require a schema migration).apps/ai-studio/src/nodes/ai-agent/uischema.ts— text field formodel(placeholder "Inherit workflow/deployment default");provideras aSelectpopulated from['auto', 'openrouter', 'openai', 'anthropic']plus an "Other…" option that reveals a free-text input.apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts— leavemodelunset,providerdefaults to'auto'.Workflow-level default (optional, can ship as a follow-up)
packages/sdk/src/store/slices/diagram-slice.ts— adddefaultAiModel/defaultAiProvidertoDiagramState, mirroringlayoutDirection/globalVariables.packages/sdk/src/store/slices/diagram-slice/actions.ts— load them insetDiagramModel, same asglobalVariables.packages/sdk/src/types/integration.ts+packages/sdk/src/features/integration/utils/validate-integration-data.ts+integration-variants/*.tsx— round-trip the new fields through save/load.packages/sdk/src/features/variables/modals/tab-general/tab-general.tsx(or a newSETTINGS_TABStab) — UI fields for the workflow-level defaults, same dropdown+free-text pattern as the node panel.apps/backend'sworkflowstable stores the whole diagram as opaquedraftJson/publishedJson, so the new fields ride through unchanged.Execution worker
apps/execution-worker/src/model-provider.ts. A table drives both the auto-detect prefix map and the per-provider client construction, so adding a 14th provider is one table row, not a newif/case:providervalue that isn't a table key fails clearly rather than guessing — adding real support for it means adding a table row plus its dependency, not something the field silently pretends to handle today.apps/execution-worker/src/domain/ai-studio-nodes.ts—AiAgentNodeConfiggainsmodel?: stringandprovider?: string.apps/execution-worker/src/activities/ai-agent.ts—AiAgentDeps.model(a fixedLanguageModel) becomes a resolver call:const model = await resolveModel(node.config.model ?? workflowDefault.model ?? env.AI_MODEL, node.config.provider ?? workflowDefault.provider ?? 'auto', openrouter).apps/execution-worker/src/engines/temporal/worker.ts— keep building the singleopenrouterclient exactly as today; pass it (plus the resolver) intoexecuteAiAgentinstead of a pre-resolvedmodel.apps/execution-worker/src/env.ts— no changes. None of the fourteen providers' key vars are declared here.apps/execution-worker/package.json— add@ai-sdk/openai,@ai-sdk/anthropic,@ai-sdk/google,@ai-sdk/xai,@ai-sdk/mistral,@ai-sdk/cohere,@ai-sdk/deepseek,@ai-sdk/moonshotai,@ai-sdk/groq,@ai-sdk/togetherai,@ai-sdk/fireworks,@ai-sdk/perplexity,@ai-sdk/cerebras,@ai-sdk/deepinfra(all small, official packages), each imported dynamically so there's no runtime cost for the providers a deployment doesn't use.apps/execution-worker/README.md— one line per provider documenting that its standard env var is recognized-if-present, not required, and is read directly by that provider's own AI SDK package; and thatprovideraccepts free text for values not yet in the table (which currently fails at execution with a clear error until support is added).Tests
@ai-sdk/*packages — that would mean provisioning fourteen sets of API keys just for this repo's CI, and it would mostly be re-testing Vercel's own SDKs, not our code. Coverage instead comes from design, not enumeration:@ai-sdk/<x>'s owncreate<X>()factory —PROVIDER_TABLE'sloadentries are one-line calls into that factory, nothing else. Once OpenRouter's entry (already in production use) round-trips correctly throughresolveModel()→generateText(), every other table entry runs through the exact same call shape (resolveModel→ provider factory →LanguageModel→generateText) with only the factory swapped. If our dispatch mechanism is correct for one entry, it's structurally correct for all of them — the risk surface is the shared dispatch code, not each individual provider's SDK internals, which are Vercel's tested surface, not ours.'auto'resolution (prefix match + key present/absent, falling through to'openrouter'), explicit provider selection (found in the table vs. throwing when the env var is missing), an unknown free-text provider (throws clearly), and an unprefixed model id under'auto'. These same cases, run against one representative entry, validate the dispatch logic that every real provider shares.OPENROUTER_API_KEY) confirms the end-to-end path still works for at least one real provider; adding a 15th provider does not require adding a 15th live smoke test.modelandproviderindependently — again mechanism-level, independent of which specific provider is selected.Non-goals (explicitly out of scope for this request)