Skip to content
Open
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 @@ -7,4 +7,5 @@ export const defaultPropertiesData: NodeDataProperties<AiAgentSchema> = {
description: '',
systemPrompt: '',
webSearch: false,
provider: 'auto',
};
16 changes: 16 additions & 0 deletions apps/ai-studio/src/nodes/ai-agent/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { sharedProperties } from '@workflowbuilder/sdk';
import type { NodeSchema } from '@workflowbuilder/sdk';

// Kept as a plain string (not an enum) so a provider added later, or a custom
// OpenAI-compatible endpoint, doesn't require a schema migration to unlock.
const providerOptions = [
{ label: 'Auto', value: 'auto' },
{ label: 'OpenRouter', value: 'openrouter' },
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
];

export const schema = {
type: 'object',
properties: {
Expand All @@ -11,6 +20,13 @@ export const schema = {
webSearch: {
type: 'boolean',
},
model: {
type: 'string',
},
provider: {
type: 'string',
options: providerOptions,
},
},
} satisfies NodeSchema;

Expand Down
11 changes: 11 additions & 0 deletions apps/ai-studio/src/nodes/ai-agent/uischema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,16 @@ export const uischema: UISchema = {
scope: scope('properties.webSearch'),
label: 'Web search (let the agent look things up)',
},
{
type: 'Text',
scope: scope('properties.model'),
label: 'Model',
placeholder: 'Inherit workflow/deployment default',
},
{
type: 'Select',
scope: scope('properties.provider'),
label: 'Provider',
},
],
};
19 changes: 19 additions & 0 deletions apps/execution-worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,22 @@ AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
# free key at https://tavily.com (free tier ~1000 searches/month). Leave empty
# to disable: agents with web search toggled on still run, just without the tool.
TAVILY_API_KEY=

# Optional, per-provider direct-routing keys for the `ai-agent` node's `provider`
# field (see apps/execution-worker/README.md). None is required; each is only
# read by its own @ai-sdk/<x> package when that provider is selected explicitly
# or inferred via 'auto' from the model-id prefix.
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
XAI_API_KEY=
MISTRAL_API_KEY=
COHERE_API_KEY=
DEEPSEEK_API_KEY=
MOONSHOT_API_KEY=
GROQ_API_KEY=
TOGETHER_API_KEY=
FIREWORKS_API_KEY=
PERPLEXITY_API_KEY=
CEREBRAS_API_KEY=
DEEPINFRA_API_KEY=
27 changes: 27 additions & 0 deletions apps/execution-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ See `.env.example`. Required:
| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` |
| `AI_MODEL` | OpenRouter model ID | `anthropic/claude-3.5-haiku` |

Optional, per-provider direct-routing keys — none is declared here or in `env.ts`; each is read
directly by its own `@ai-sdk/<x>` package only when an `ai-agent` node selects that `provider`
explicitly (or `'auto'` infers it from the model-id prefix). Presence is checked with
`Boolean(process.env.<VAR>)`; the key value itself is never read, stored, or logged by this app.

| Provider | Env var |
| ------------ | ------------------------------ |
| `openai` | `OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY` |
| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
| `xai` | `XAI_API_KEY` |
| `mistral` | `MISTRAL_API_KEY` |
| `cohere` | `COHERE_API_KEY` |
| `deepseek` | `DEEPSEEK_API_KEY` |
| `moonshotai` | `MOONSHOT_API_KEY` |
| `groq` | `GROQ_API_KEY` |
| `togetherai` | `TOGETHER_API_KEY` |
| `fireworks` | `FIREWORKS_API_KEY` |
| `perplexity` | `PERPLEXITY_API_KEY` |
| `cerebras` | `CEREBRAS_API_KEY` |
| `deepinfra` | `DEEPINFRA_API_KEY` |

An `ai-agent` node's `model`/`provider` config fields fall back to `env.AI_MODEL`/`'auto'` when
unset (`apps/execution-worker/src/model-provider.ts`). `provider` accepts free text for a value
not yet in this table — that currently fails at execution with a clear error until support (a
table row plus its `@ai-sdk/<x>` dependency) is added.

## Structure

```
Expand Down
14 changes: 14 additions & 0 deletions apps/execution-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.118",
"@ai-sdk/cerebras": "^2.0.81",
"@ai-sdk/cohere": "^3.0.61",
"@ai-sdk/deepinfra": "^2.0.79",
"@ai-sdk/deepseek": "^2.0.64",
"@ai-sdk/fireworks": "^2.0.85",
"@ai-sdk/google": "^3.0.122",
"@ai-sdk/groq": "^3.0.66",
"@ai-sdk/mistral": "^3.0.64",
"@ai-sdk/moonshotai": "^2.0.56",
"@ai-sdk/openai": "^3.0.112",
"@ai-sdk/perplexity": "^3.0.60",
"@ai-sdk/togetherai": "^2.0.81",
"@ai-sdk/xai": "^3.0.132",
"@openrouter/ai-sdk-provider": "^2.5.0",
"@temporalio/worker": "catalog:",
"@temporalio/workflow": "catalog:",
Expand Down
65 changes: 64 additions & 1 deletion apps/execution-worker/src/activities/ai-agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { APICallError } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import type { ExecutionContext } from '@workflow-builder/execution-core';

import type { AiAgentNode } from '../domain/ai-studio-nodes';
import type { OpenRouterClient } from '../model-provider';
import { executeAiAgent } from './ai-agent';

function context(): ExecutionContext {
Expand Down Expand Up @@ -64,3 +65,65 @@ describe('executeAiAgent', () => {
expect(model.doGenerateCalls).toHaveLength(1);
});
});

function mockModel(text: string) {
return new MockLanguageModelV3({
doGenerate: {
content: [{ type: 'text', text }],
finishReason: { unified: 'stop', raw: undefined },
usage: {
inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
},
warnings: [],
},
});
}

function fakeOpenrouter(chat: (id: string) => unknown): OpenRouterClient {
return { chat: vi.fn(chat) } as unknown as OpenRouterClient;
}

describe('executeAiAgent model/provider fallback chain', () => {
it("uses node.config.model over deps.defaultModel when resolving via 'auto'/openrouter", async () => {
const chat = vi.fn(() => mockModel('ok'));
const openrouter = fakeOpenrouter(chat);
const node: AiAgentNode = {
id: 'agent1',
type: 'ai-studio/ai-agent',
config: { systemPrompt: 'p', model: 'node-model' },
};

await executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' });

expect(chat).toHaveBeenCalledWith('node-model');
});

it('falls back to deps.defaultModel when node.config.model is unset', async () => {
const chat = vi.fn(() => mockModel('ok'));
const openrouter = fakeOpenrouter(chat);
const node: AiAgentNode = {
id: 'agent1',
type: 'ai-studio/ai-agent',
config: { systemPrompt: 'p' },
};

await executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' });

expect(chat).toHaveBeenCalledWith('env-model');
});

it('throws when node.config.provider selects a known provider whose API key is missing', async () => {
const openrouter = fakeOpenrouter(() => mockModel('unused'));
delete process.env['OPENAI_API_KEY'];
const node: AiAgentNode = {
id: 'agent1',
type: 'ai-studio/ai-agent',
config: { systemPrompt: 'p', model: 'gpt-4o-mini', provider: 'openai' },
};

await expect(executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' })).rejects.toThrow(
'OPENAI_API_KEY',
);
});
});
20 changes: 18 additions & 2 deletions apps/execution-worker/src/activities/ai-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import { generateText, stepCountIs } from 'ai';
import { type ExecutionContext, type LoggerPort, resolveTemplate } from '@workflow-builder/execution-core';

import type { AiAgentNode } from '../domain/ai-studio-nodes';
import type { OpenRouterClient } from '../model-provider';
import { resolveModel } from '../model-provider';
import { createWebSearchTool } from '../tools/web-search';

// Bounds the agentic tool loop so a misbehaving model can't run up cost.
const MAX_TOOL_STEPS = 4;

type AiAgentDeps = {
model: Parameters<typeof generateText>[0]['model'];
// A fixed model bypasses per-node resolution entirely (used by tests).
// A default model id/provider plus an OpenRouter client resolves per node,
// via node.config.model/provider falling back to these defaults.
model?: Parameters<typeof generateText>[0]['model'];
defaultModel?: string;
defaultProvider?: string;
openrouter?: OpenRouterClient;
logger?: LoggerPort;
tavilyApiKey?: string;
};
Expand Down Expand Up @@ -44,9 +52,17 @@ export async function executeAiAgent(node: AiAgentNode, context: ExecutionContex
const webSearchEnabled = node.config.webSearch === true && Boolean(deps.tavilyApiKey);
const tools = webSearchEnabled ? { webSearch: createWebSearchTool(deps.tavilyApiKey!) } : undefined;

const model =
deps.model ??
(await resolveModel(
node.config.model ?? deps.defaultModel!,
node.config.provider ?? deps.defaultProvider ?? 'auto',
deps.openrouter!,
));

try {
const result = await generateText({
model: deps.model,
model,
// Temporal's activity retry policy owns retries; SDK retries on top would
// multiply model calls (up to 3x per activity attempt).
maxRetries: 0,
Expand Down
2 changes: 2 additions & 0 deletions apps/execution-worker/src/domain/ai-studio-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type TriggerNodeConfig = Record<string, never>;
type AiAgentNodeConfig = {
systemPrompt: string; // supports {{namespace.path}} template references
webSearch?: boolean; // needs TAVILY_API_KEY to take effect
model?: string; // unset inherits env.AI_MODEL
provider?: string; // 'auto' | 'openrouter' | known provider id | free text; unset behaves as 'auto'
};

export type DecisionBranchCondition = {
Expand Down
8 changes: 6 additions & 2 deletions apps/execution-worker/src/engines/temporal/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { withPayloadSizeWarning } from '../../store-payload-warning';
const { createOpenRouter } = await import('@openrouter/ai-sdk-provider');

const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY });
const model = openrouter.chat(env.AI_MODEL);

const aiAgentLogger = logger.child({ component: 'ai-agent' });

Expand All @@ -27,7 +26,12 @@ const plugin = new WorkflowBuilderPlugin<AiStudioNode>({
'ai-studio/trigger': executeTrigger,
'ai-studio/decision': executeDecision,
'ai-studio/ai-agent': (node, context) =>
executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }),
executeAiAgent(node, context, {
openrouter,
defaultModel: env.AI_MODEL,
logger: aiAgentLogger,
tavilyApiKey: env.TAVILY_API_KEY,
}),
'ai-studio/visualize': executeVisualize,
},
store: withPayloadSizeWarning(database, logger),
Expand Down
81 changes: 81 additions & 0 deletions apps/execution-worker/src/model-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { resolveModel } from './model-provider';

function fakeOpenrouter() {
return { chat: vi.fn((id: string) => ({ modelId: id, kind: 'openrouter' })) } as unknown as Parameters<
typeof resolveModel
>[2];
}

describe('resolveModel', () => {
const originalEnv = { ...process.env };

beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.endsWith('_API_KEY')) delete process.env[key];
}
});

afterEach(() => {
process.env = { ...originalEnv };
});

it("falls back to openrouter under 'auto' when no matching provider key is present", async () => {
const openrouter = fakeOpenrouter();

const model = await resolveModel('mistralai/mistral-small', 'auto', openrouter);

expect(openrouter.chat).toHaveBeenCalledWith('mistralai/mistral-small');
expect(model).toEqual({ modelId: 'mistralai/mistral-small', kind: 'openrouter' });
});

it("resolves an unprefixed model id under 'auto' to openrouter", async () => {
const openrouter = fakeOpenrouter();

await resolveModel('some-custom-model', 'auto', openrouter);

expect(openrouter.chat).toHaveBeenCalledWith('some-custom-model');
});

it("infers the direct provider under 'auto' when its prefix matches and its key is present", async () => {
process.env['OPENAI_API_KEY'] = 'test-key';
const openrouter = fakeOpenrouter();

const model = await resolveModel('openai/gpt-4o-mini', 'auto', openrouter);

expect(openrouter.chat).not.toHaveBeenCalled();
expect(model).toBeDefined();
});

it('routes to openrouter when explicitly selected', async () => {
const openrouter = fakeOpenrouter();

await resolveModel('anthropic/claude-3-haiku', 'openrouter', openrouter);

expect(openrouter.chat).toHaveBeenCalledWith('anthropic/claude-3-haiku');
});

it('throws when an explicit known provider is selected but its API key env var is missing', async () => {
const openrouter = fakeOpenrouter();

await expect(resolveModel('gpt-4o-mini', 'openai', openrouter)).rejects.toThrow('OPENAI_API_KEY');
});

it('resolves an explicit known provider when its API key env var is present', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
const openrouter = fakeOpenrouter();

const model = await resolveModel('claude-3-haiku', 'anthropic', openrouter);

expect(model).toBeDefined();
});

it('throws clearly for an unknown free-text provider', async () => {
const openrouter = fakeOpenrouter();

await expect(resolveModel('some-model', 'not-a-real-provider', openrouter)).rejects.toThrow(
'Unknown provider "not-a-real-provider"',
);
});
});
Loading