OpenAI Node SDK tool calling + Zod, tested with a mocked fetch: working versions (tested 2026-10-15)
Run an OpenAI chat tool-calling loop with Zod-validated tool arguments, tested offline by injecting a fake fetch into the official SDK.
passed2 passed · 0 failed2026-10-15 11:55 UTC- Status
- passed
- Last passed
- 2026-10-15 11:55 UTC
- Tests
- 2 / 2
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38730348195
The code
Exactly the files that passed. Your agent can pull them with get_recipe("openai-sdk-mocked-tool-calls") (install), or see them on GitHub.
{
"name": "openai-sdk-mocked-tool-calls",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"openai": "7.34.0",
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import OpenAI from "openai";
import { z } from "zod";
const WeatherArgs = z.object({ city: z.string().min(1) });
export const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [{
type: "function",
function: { name: "get_weather", description: "Current weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } },
}];
export async function runAgent(client: OpenAI, question: string, getWeather: (city: string) => Promise<string>) {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [{ role: "user", content: question }];
for (let turn = 0; turn < 5; turn++) {
const res = await client.chat.completions.create({ model: "gpt-4o-mini", messages, tools });
const msg = res.choices[0].message;
messages.push(msg);
if (!msg.tool_calls?.length) return msg.content ?? "";
for (const call of msg.tool_calls) {
if (call.type !== "function") continue;
const parsed = WeatherArgs.safeParse(JSON.parse(call.function.arguments));
const content = parsed.success ? await getWeather(parsed.data.city) : `invalid arguments: ${parsed.error.message}`;
messages.push({ role: "tool", tool_call_id: call.id, content });
}
}
throw new Error("too many tool turns");
}
import { test } from "node:test";
import assert from "node:assert/strict";
import OpenAI from "openai";
import { runAgent } from "../src/agent.ts";
function completion(message: object) {
return { id: "c1", object: "chat.completion", created: 0, model: "gpt-4o-mini", choices: [{ index: 0, finish_reason: "stop", logprobs: null, message: { role: "assistant", content: null, refusal: null, ...message } }] };
}
function fakeClient(replies: object[], seen: any[]) {
const fetch = async (_url: any, init: any) => {
seen.push(JSON.parse(init.body));
return new Response(JSON.stringify(replies.shift()), { status: 200, headers: { "content-type": "application/json" } });
};
return new OpenAI({ apiKey: "test", baseURL: "http://mock.local/v1", fetch: fetch as any, maxRetries: 0 });
}
test("calls the tool then answers", async () => {
const seen: any[] = [];
const client = fakeClient([
completion({ tool_calls: [{ id: "t1", type: "function", function: { name: "get_weather", arguments: JSON.stringify({ city: "Anchorage" }) } }] }),
completion({ content: "It is 2C in Anchorage." }),
], seen);
const answer = await runAgent(client, "Weather in Anchorage?", async (c) => `2C in ${c}`);
assert.equal(answer, "It is 2C in Anchorage.");
const toolMsg = seen[1].messages.find((m: any) => m.role === "tool");
assert.deepEqual(toolMsg, { role: "tool", tool_call_id: "t1", content: "2C in Anchorage" });
assert.equal(seen[0].tools[0].function.name, "get_weather");
});
test("bad tool arguments are reported back, not thrown", async () => {
const seen: any[] = [];
const client = fakeClient([
completion({ tool_calls: [{ id: "t1", type: "function", function: { name: "get_weather", arguments: "{}" } }] }),
completion({ content: "Which city?" }),
], seen);
assert.equal(await runAgent(client, "Weather?", async () => "x"), "Which city?");
assert.match(seen[1].messages.at(-1).content, /invalid arguments/);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
CSV import with PapaParse + Zod row validation
Parse CSV uploads with PapaParse (headers, BOM, quoted fields), validate each row with Zod and report row-numbered errors instead of failing the whole file.
Drizzle ORM + drizzle-zod validated inserts on SQLite
Generate Zod insert and select schemas from Drizzle tables with drizzle-zod, refine them, and validate input before inserting into SQLite.
Express 5 + Zod 4 request validation
Validate JSON bodies in Express 5 with a reusable Zod middleware that returns 400 with field errors.