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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ Many of our Toolkits require extra keys. You can run Toolkit without these keys,

##### 5.1) Web Search Toolkit

Toolkit uses Exa for web search. Get an API key [here](https://dashboard.exa.ai/api-keys) and add it to your `.env`
New installations without search credentials start with **Parallel Web Search** enabled. It provides free web search (Fast mode) and page fetching through [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp), with no search account or API key. Remove it from the toolkit selector to disable it; the selection is saved. You can add it again from **Add Toolkits**.

Saved toolkit selections (including an empty selection), workbench settings, native-search preferences, and configured Exa or OpenAI credentials take precedence over this initial default. Parallel requests fail visibly on errors or service limits, with a 20-second deadline; they never switch providers automatically. Model tool-calling support is still required.

Exa remains available as **Web Search**, including its specialized research tools. To enable it, get an API key [here](https://dashboard.exa.ai/api-keys) and add it to your `.env`

```
EXA_API_KEY=<your API key>
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"start": "next start",
"typecheck": "tsc --noEmit",
"ci": "pnpm run format:check && pnpm run check && pnpm run build-pure",
"shiki:generate": "pnpm dlx tsx src/components/ui/code/codegen.ts && pnpm format:write"
"shiki:generate": "pnpm dlx tsx src/components/ui/code/codegen.ts && pnpm format:write",
"test:parallel": "SKIP_ENV_VALIDATION=1 TSX_TSCONFIG_PATH=tsconfig.test.json node --import ./tests/register.mjs --import tsx --test tests/parallel.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
Expand Down
6 changes: 6 additions & 0 deletions src/app/(general)/_contexts/chat-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export function ChatProvider({
const [hasInvalidated, setHasInvalidated] = useState(false);
const [streamStopped, setStreamStopped] = useState(false);

// Persist the initial toolkit default as well as subsequent selections. A
// workbench's initial toolkits must not overwrite the ordinary chat preference.
useEffect(() => {
if (!workbench) clientCookieUtils.setToolkits(toolkits);
}, [toolkits, workbench]);

// Wrapper functions that also save to cookies
const setSelectedChatModel = (model: LanguageModel) => {
setSelectedChatModelState(model);
Expand Down
99 changes: 15 additions & 84 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
convertToCoreMessages,
createDataStream,
smoothStream,
tool,
} from "ai";

import { createResumableStreamContext } from "resumable-stream";
Expand All @@ -25,15 +24,10 @@ import { generateUUID } from "@/lib/utils";
import { ChatSDKError } from "@/lib/errors";

import type { ResumableStreamContext } from "resumable-stream";
import type {
CoreAssistantMessage,
CoreToolMessage,
Tool,
UIMessage,
} from "ai";
import type { CoreAssistantMessage, CoreToolMessage, UIMessage } from "ai";
import type { Chat } from "@prisma/client";
import { openai } from "@ai-sdk/openai";
import { getServerToolkit } from "@/toolkits/toolkits/server";
import { resolveToolkitTools } from "@/toolkits/resolve-tools";
import { languageModels } from "@/ai/language";

export const maxDuration = 60;
Expand Down Expand Up @@ -154,82 +148,18 @@ export async function POST(request: Request) {
const streamId = generateUUID();
await api.streams.createStreamId({ streamId, chatId: id });

const toolkitTools = await Promise.all(
toolkits.map(async ({ id, parameters }) => {
const toolkit = getServerToolkit(id);
const tools = await toolkit.tools(parameters);
return Object.keys(tools).reduce(
(acc, toolName) => {
const serverTool = tools[toolName as keyof typeof tools];
acc[`${id}_${toolName}`] = tool({
description: serverTool.description,
parameters: serverTool.inputSchema,
execute: async (args) => {
try {
const result = await serverTool.callback(args);

// Increment tool usage on successful execution
try {
const serverCaller = await createServerOnlyCaller();
await serverCaller.tools.incrementToolUsageServer({
toolkit: id,
tool: toolName,
});
} catch (error) {
console.error("Failed to increment tool usage:", error);
}

if (serverTool.message) {
return {
result,
message:
typeof serverTool.message === "function"
? serverTool.message(result)
: serverTool.message,
};
} else {
return {
result,
};
}
} catch (error) {
console.error(error);
return {
isError: true,
result: {
error:
error instanceof Error
? error.message
: "An error occurred while executing the tool",
},
};
}
},
});
return acc;
},
{} as Record<string, Tool>,
);
}),
);

// Collect toolkit system prompts
const toolkitSystemPrompts = await Promise.all(
toolkits.map(async ({ id }) => {
const toolkit = getServerToolkit(id);
return toolkit.systemPrompt;
}),
);

const tools = toolkitTools.reduce(
(acc, toolkitTools) => {
return {
...acc,
...toolkitTools,
};
},
{} as Record<string, Tool>,
);
const { tools, systemPrompts: toolkitSystemPrompts } =
await resolveToolkitTools(
toolkits,
{ sessionId: id, modelName: selectedChatModel },
async (toolkit, name) => {
const serverCaller = await createServerOnlyCaller();
await serverCaller.tools.incrementToolUsageServer({
toolkit,
tool: name,
});
},
);

const isOpenAi = selectedChatModel.startsWith("openai");

Expand All @@ -249,6 +179,7 @@ export async function POST(request: Request) {
`${selectedChatModel}${useNativeSearch ? ":search" : ""}`,
{
system: fullSystemPrompt,
abortSignal: request.signal,
messages: convertToCoreMessages(messages),
maxSteps: 15,
toolCallStreaming: true,
Expand Down
13 changes: 13 additions & 0 deletions src/lib/cookies/default-toolkits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Toolkits } from "@/toolkits/toolkits/shared";
import type { PersistedToolkit } from "./types";

export function resolveInitialToolkits(
saved: PersistedToolkit[] | undefined,
hasSearchCredentials: boolean,
hasNativeSearchPreference: boolean,
): PersistedToolkit[] {
// An explicitly empty selection is a disabled-tool preference.
if (saved !== undefined) return saved;
if (hasSearchCredentials || hasNativeSearchPreference) return [];
return [{ id: Toolkits.Parallel, parameters: {} }];
}
12 changes: 9 additions & 3 deletions src/lib/cookies/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cookies } from "next/headers";
import type { ChatPreferences } from "./types";
import { COOKIE_KEYS } from "./keys";
import { resolveInitialToolkits } from "./default-toolkits";

// Helper to safely parse JSON from cookie value
const safeParseJson = <T>(value: string | null | undefined, fallback: T): T => {
Expand Down Expand Up @@ -32,9 +33,14 @@ export const serverCookieUtils = {
cookieStore.get(COOKIE_KEYS.USE_NATIVE_SEARCH)?.value,
false,
),
toolkits: safeParseJson(
cookieStore.get(COOKIE_KEYS.TOOLKITS)?.value,
[],
toolkits: resolveInitialToolkits(
safeParseJson(
cookieStore.get(COOKIE_KEYS.TOOLKITS)?.value,
cookieStore.has(COOKIE_KEYS.TOOLKITS) ? [] : undefined,
),
Boolean(process.env.EXA_API_KEY) ||
Boolean(process.env.OPENAI_API_KEY),
cookieStore.has(COOKIE_KEYS.USE_NATIVE_SEARCH),
),
};
} catch (error) {
Expand Down
67 changes: 67 additions & 0 deletions src/toolkits/resolve-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { tool, type Tool } from "ai";
import { getServerToolkit } from "./toolkits/server";
import type { Toolkits } from "./toolkits/shared";
import type { ToolExecutionContext } from "./types";

export async function resolveToolkitTools(
selected: Array<{ id: Toolkits; parameters: Record<string, unknown> }>,
context: ToolExecutionContext,
onSuccess?: (toolkit: Toolkits, name: string) => Promise<void>,
) {
const loaded = await Promise.all(
selected.map(async ({ id, parameters }) => {
const toolkit = getServerToolkit(id);
const serverTools = await toolkit.tools(parameters);
const tools: Record<string, Tool> = {};
for (const [name, serverTool] of Object.entries(serverTools)) {
tools[`${id}_${name}`] = tool({
description: serverTool.description,
parameters: serverTool.inputSchema,
execute: async (args, { abortSignal }) => {
try {
const result = await serverTool.callback(args, {
...context,
...(abortSignal ? { abortSignal } : {}),
});
try {
await onSuccess?.(id, name);
} catch (error) {
console.error("Failed to increment tool usage:", error);
}
return {
result,
...(serverTool.message
? {
message:
typeof serverTool.message === "function"
? serverTool.message(result)
: serverTool.message,
}
: {}),
};
} catch (error) {
console.error(error);
return {
isError: true,
result: {
error:
error instanceof Error
? error.message
: "An error occurred while executing the tool",
},
};
}
},
});
}
return { tools, systemPrompt: toolkit.systemPrompt };
}),
);
return {
tools: loaded.reduce<Record<string, Tool>>(
(all, toolkit) => ({ ...all, ...toolkit.tools }),
{},
),
systemPrompts: loaded.map((toolkit) => toolkit.systemPrompt),
};
}
2 changes: 2 additions & 0 deletions src/toolkits/toolkits/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "./shared";

import { exaClientToolkit } from "./exa/client";
import { parallelClientToolkit } from "./parallel/client";
import { imageClientToolkit } from "./image/client";
import { githubClientToolkit } from "./github/client";
import { googleCalendarClientToolkit } from "./google-calendar/client";
Expand All @@ -28,6 +29,7 @@ export type ClientToolkits = {
};

export const clientToolkits: ClientToolkits = {
[Toolkits.Parallel]: parallelClientToolkit,
[Toolkits.E2B]: e2bClientToolkit,
[Toolkits.Memory]: mem0ClientToolkit,
[Toolkits.Image]: imageClientToolkit,
Expand Down
57 changes: 57 additions & 0 deletions src/toolkits/toolkits/parallel/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { z } from "zod";
import { createBaseTool } from "@/toolkits/create-tool";

export const httpUrl = z
.string()
.url()
.refine((value) => {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!url.username &&
!url.password
);
}, "Use an HTTP or HTTPS URL without credentials");

export enum ParallelTools {
Search = "search",
Fetch = "fetch",
}

export const parallelParameters = z.object({});

export const parallelOutput = z.object({
results: z.array(
z.object({
title: z.string().nullable(),
url: httpUrl,
content: z.string(),
publishedDate: z.string().optional(),
}),
),
warnings: z.array(z.string()),
});

export const baseParallelToolkitConfig = {
parameters: parallelParameters,
tools: {
[ParallelTools.Search]: createBaseTool({
description:
"Search the web for current information. Use the returned excerpts to answer and cite the source URLs.",
inputSchema: z.object({
objective: z.string().trim().min(1).max(1000),
queries: z.array(z.string().trim().min(1).max(200)).min(1).max(5),
}),
outputSchema: parallelOutput,
}),
[ParallelTools.Fetch]: createBaseTool({
description:
"Read specific web pages when the user provides a URL or search excerpts are insufficient.",
inputSchema: z.object({
urls: z.array(httpUrl).min(1).max(20),
objective: z.string().trim().min(1).max(200).optional(),
}),
outputSchema: parallelOutput,
}),
},
};
Loading