chat_completions tool call fixes (#2434)

* chat_completions tool call fixes

* update chat completions test
This commit is contained in:
Neal Shah
2026-02-06 15:10:27 -08:00
committed by GitHub
parent 06fe07e384
commit 28cedec9de
7 changed files with 509 additions and 23 deletions
+138 -4
View File
@@ -14,6 +14,7 @@
"experiments/js-parse-and-output"
],
"dependencies": {
"@ai-sdk/openai": "^3.0.25",
"@anthropic-ai/sdk": "^0.68.0",
"@aws-sdk/client-dynamodb": "^3.490.0",
"@aws-sdk/client-secrets-manager": "^3.879.0",
@@ -23,6 +24,7 @@
"@heyputer/putility": "^1.0.2",
"@paralleldrive/cuid2": "^2.2.2",
"@stylistic/eslint-plugin-js": "^4.4.1",
"ai": "^6.0.73",
"dedent": "^1.5.3",
"dynalite": "^4.0.0",
"express-xml-bodyparser": "^0.4.1",
@@ -80,6 +82,68 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.36",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.36.tgz",
"integrity": "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.7",
"@ai-sdk/provider-utils": "4.0.13",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.25.tgz",
"integrity": "sha512-DsaN46R98+D1W3lU3fKuPU3ofacboLaHlkAwxJPgJ8eup1AJHmPK1N1y10eJJbJcF6iby8Tf/vanoZxc9JPUfw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.7",
"@ai-sdk/provider-utils": "4.0.13"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.7.tgz",
"integrity": "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.13",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.13.tgz",
"integrity": "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.7",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.68.0",
"license": "MIT",
@@ -342,7 +406,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.980.0.tgz",
"integrity": "sha512-1rGhAx4cHZy3pMB3R3r84qMT5WEvQ6ajr2UksnD48fjQxwaUcpI6NsPvU5j/5BI5LqGiUO6ThOrMwSMm95twQA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -396,7 +459,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz",
"integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
@@ -1241,6 +1303,7 @@
"version": "7.28.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1557,6 +1620,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1595,6 +1659,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3367,6 +3432,7 @@
"node_modules/@jimp/custom": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/core": "^0.22.12"
}
@@ -3397,6 +3463,7 @@
"node_modules/@jimp/plugin-blit": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3407,6 +3474,7 @@
"node_modules/@jimp/plugin-blur": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3427,6 +3495,7 @@
"node_modules/@jimp/plugin-color": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12",
"tinycolor2": "^1.6.0"
@@ -3464,6 +3533,7 @@
"node_modules/@jimp/plugin-crop": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3567,6 +3637,7 @@
"node_modules/@jimp/plugin-resize": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3577,6 +3648,7 @@
"node_modules/@jimp/plugin-rotate": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3590,6 +3662,7 @@
"node_modules/@jimp/plugin-scale": {
"version": "0.22.12",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3830,6 +3903,7 @@
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -5126,8 +5200,9 @@
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"dev": true,
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stylistic/eslint-plugin": {
@@ -5661,6 +5736,7 @@
"version": "8.48.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -5852,6 +5928,15 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.14",
"dev": true,
@@ -6283,6 +6368,7 @@
"node_modules/acorn": {
"version": "8.15.0",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6350,6 +6436,24 @@
"node": ">=8"
}
},
"node_modules/ai": {
"version": "6.0.73",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.73.tgz",
"integrity": "sha512-p2/ICXIjAM4+bIFHEkAB+l58zq+aTmxAkotsb6doNt/CEms72zt6gxv2ky1fQDwU4ecMOcmMh78VJUSEKECzlg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.36",
"@ai-sdk/provider": "3.0.7",
"@ai-sdk/provider-utils": "4.0.13",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"license": "MIT",
@@ -6917,6 +7021,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -7115,6 +7220,7 @@
"node_modules/chai": {
"version": "4.5.0",
"license": "MIT",
"peer": true,
"dependencies": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.3",
@@ -8408,6 +8514,7 @@
"node_modules/eslint": {
"version": "9.39.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8603,6 +8710,15 @@
"node": ">=0.8.x"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/exif-parser": {
"version": "0.1.12"
},
@@ -10357,6 +10473,7 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "1.5.0",
"cluster-key-slot": "^1.1.0",
@@ -11008,6 +11125,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"license": "MIT",
@@ -14213,6 +14336,7 @@
"version": "8.17.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -15654,6 +15778,7 @@
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15826,6 +15951,7 @@
"version": "7.2.6",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -15917,6 +16043,7 @@
"version": "4.0.14",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.14",
"@vitest/mocker": "4.0.14",
@@ -16030,6 +16157,7 @@
"version": "5.103.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -16077,6 +16205,7 @@
"version": "5.1.4",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -16275,6 +16404,7 @@
"node_modules/winston": {
"version": "3.18.3",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -16392,6 +16522,7 @@
"node_modules/ws": {
"version": "8.18.3",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -16581,6 +16712,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -16701,6 +16833,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz",
"integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -16710,6 +16843,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.49.1.tgz",
"integrity": "sha512-kaNl/T7WzyMUQHQlVq7q0oV4Kev6+0xFwqzofryC66jgGMacd0QH5TwfpbUwSTby+SdAdprAe5UKMvBw4tKS5Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api": "^1.0.0"
},
+2
View File
@@ -70,6 +70,7 @@
]
},
"dependencies": {
"@ai-sdk/openai": "^3.0.25",
"@anthropic-ai/sdk": "^0.68.0",
"@aws-sdk/client-dynamodb": "^3.490.0",
"@aws-sdk/client-secrets-manager": "^3.879.0",
@@ -79,6 +80,7 @@
"@heyputer/putility": "^1.0.2",
"@paralleldrive/cuid2": "^2.2.2",
"@stylistic/eslint-plugin-js": "^4.4.1",
"ai": "^6.0.73",
"dedent": "^1.5.3",
"dynalite": "^4.0.0",
"express-xml-bodyparser": "^0.4.1",
@@ -132,6 +132,8 @@ module.exports = eggspress('/openai/v1/chat/completions', {
let buffer = '';
let usage = null;
let toolCallIndex = 0;
let sawToolCalls = false;
const sendChunk = (delta, finishReason = null, extra = {}) => {
const payload = {
@@ -170,9 +172,11 @@ module.exports = eggspress('/openai/v1/chat/completions', {
sendChunk({ content: event.text });
}
if ( event.type === 'tool_use' ) {
sawToolCalls = true;
sendChunk({
tool_calls: [
{
index: toolCallIndex++,
id: event.id,
type: 'function',
function: {
@@ -181,7 +185,7 @@ module.exports = eggspress('/openai/v1/chat/completions', {
},
},
],
}, 'tool_calls');
});
}
if ( event.type === 'usage' ) {
usage = event.usage;
@@ -190,7 +194,8 @@ module.exports = eggspress('/openai/v1/chat/completions', {
});
streamValue.on('end', () => {
sendChunk({}, 'stop', usage ? { usage: buildUsage(usage) } : {});
const finishReason = sawToolCalls ? 'tool_calls' : 'stop';
sendChunk({}, finishReason, usage ? { usage: buildUsage(usage) } : {});
res.write('data: [DONE]\n\n');
res.end();
});
@@ -54,9 +54,13 @@ export const normalize_tools_object = (tools) => {
fn.parameters ||
fn.input_schema;
normal_fn.parameters = parameters ?? {
type: 'object',
};
if ( !parameters || typeof parameters !== 'object' ) {
parameters = { type: 'object' };
} else if ( ! parameters.type ) {
parameters.type = 'object';
}
normal_fn.parameters = parameters;
if ( parameters.properties ) {
parameters = normalize_json_schema(parameters);
+40 -14
View File
@@ -44,6 +44,22 @@ export const normalize_single_message = (message, params = {}) => {
throw new Error('each message must have a \'content\' property');
}
}
// Normalize OpenAI-style tool results into internal tool_result blocks
if ( message.role === 'tool' ) {
const tool_use_id = message.tool_call_id || message.tool_use_id || message.id;
const tool_content = message.content;
message.tool_use_id = tool_use_id;
message.content = [
{
type: 'tool_result',
tool_use_id,
content: typeof tool_content === 'string'
? tool_content
: JSON.stringify(tool_content ?? {}),
},
];
}
if ( ! Array.isArray(message.content) ) {
message.content = [message.content];
}
@@ -90,33 +106,43 @@ export const normalize_messages = (messages, params = {}) => {
messages[i] = normalize_single_message(messages[i], params);
}
// Split messages with tool_use content into separate messages
// Split messages with multiple content blocks into separate messages.
// Keep assistant tool_use blocks together to preserve OpenAI tool-call ordering.
// TODO: unit test this
messages = [...messages];
for ( let i = 0 ; i < messages.length ; i++ ) {
let message = messages[i];
let separated_messages = [];
const has_tool_use = message.role === 'assistant' &&
message.content?.some(c => c?.type === 'tool_use');
if ( has_tool_use ) {
separated_messages.push(message);
messages.splice(i, 1, ...separated_messages);
continue;
}
for ( let j = 0 ; j < message.content.length ; j++ ) {
if ( message.content[j].type === 'tool_result' ) {
separated_messages.push({
...message,
content: [message.content[j]],
});
} else {
separated_messages.push({
...message,
content: [message.content[j]],
});
}
separated_messages.push({
...message,
content: [message.content[j]],
});
}
messages.splice(i, 1, ...separated_messages);
}
// If multiple messages are from the same role, merge them
// but avoid merging tool_use/tool_result messages, since order matters
const hasToolContent = (message) => {
if ( !message || !Array.isArray(message.content) ) return false;
return message.content.some((part) =>
part && (part.type === 'tool_use' || part.type === 'tool_result'));
};
let merged_messages = [];
let current_role = null;
for ( let i = 0 ; i < messages.length ; i++ ) {
if ( current_role === messages[i].role ) {
const can_merge = current_role === messages[i].role &&
!hasToolContent(messages[i]) &&
!hasToolContent(merged_messages[merged_messages.length - 1]);
if ( can_merge ) {
merged_messages[merged_messages.length - 1].content.push(...messages[i].content);
} else {
merged_messages.push(messages[i]);
@@ -180,4 +206,4 @@ export const extract_text = (messages) => {
return '';
}
}).join(' ');
};
};
+1
View File
@@ -2,6 +2,7 @@ api_url: http://api.puter.localhost:4100
frontend_url: http://puter.localhost:4100
username: admin
auth_token: <your-token>
do_expensive_ai_tests: false
mountpoints:
- path: /
provider: puterfs
@@ -0,0 +1,314 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import OpenAI from 'openai';
import { createOpenAI } from '@ai-sdk/openai';
import { jsonSchema, stepCountIs, streamText, tool } from 'ai';
interface ClientConfig {
api_url: string;
auth_token?: string;
do_expensive_ai_tests?: boolean;
}
const loadConfig = (): ClientConfig => {
const envApiUrl = process.env.PUTER_API_URL;
const envAuthToken = process.env.PUTER_AUTH_TOKEN;
if ( envApiUrl ) {
return {
api_url: envApiUrl,
auth_token: envAuthToken,
do_expensive_ai_tests: process.env.PUTER_DO_EXPENSIVE_AI_TESTS === 'true',
};
}
const configPath = path.join(__dirname, '../client-config.yaml');
if ( ! fs.existsSync(configPath) ) {
throw new Error('Missing client-config.yaml. Create tests/client-config.yaml ' +
'or set PUTER_API_URL and PUTER_AUTH_TOKEN.');
}
return yaml.parse(fs.readFileSync(configPath, 'utf8')) as ClientConfig;
};
const buildHeaders = (authToken?: string) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if ( authToken ) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
};
const postChat = async (body: unknown) => {
const config = loadConfig();
const url = `${config.api_url}/puterai/openai/v1/chat/completions`;
const response = await fetch(url, {
method: 'POST',
headers: buildHeaders(config.auth_token),
body: JSON.stringify(body),
});
return { response, config };
};
describe('Puter OpenAI-Compatible Chat Completions', () => {
it('works with the OpenAI SDK (tool round-trip)', async () => {
const config = loadConfig();
if ( ! config.do_expensive_ai_tests ) return;
const apiKey = config.auth_token || process.env.OPENAI_API_KEY;
if ( ! apiKey ) throw new Error('Missing auth token for OpenAI SDK test');
const client = new OpenAI({
apiKey,
baseURL: `${config.api_url}/puterai/openai/v1`,
});
const tools = [
{
type: 'function',
function: {
name: 'calculate',
description: 'Perform a mathematical calculation',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Mathematical expression to evaluate',
},
},
required: ['expression'],
},
},
},
];
const messages = [
{
role: 'user',
content: 'Use the calculate tool to compute 2 + 2.',
},
];
const first = await client.chat.completions.create({
model: 'claude-haiku-4-5',
messages,
tools,
tool_choice: { type: 'function', function: { name: 'calculate' } },
});
const toolCalls = first.choices[0]?.message?.tool_calls ?? [];
expect(toolCalls.length).toBeGreaterThan(0);
const toolResults = toolCalls.map((toolCall: any) => ({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({ expression: '2 + 2', result: 4 }),
}));
const followup = await client.chat.completions.create({
model: 'claude-haiku-4-5',
messages: [
...messages,
{ role: 'assistant', tool_calls: toolCalls },
...toolResults,
],
});
expect(followup.choices[0]?.message?.content).toBeTruthy();
}, 20000);
it('works with the Vercel AI SDK (tool round-trip)', async () => {
const config = loadConfig();
if ( ! config.do_expensive_ai_tests ) return;
const apiKey = config.auth_token || process.env.OPENAI_API_KEY;
if ( ! apiKey ) throw new Error('Missing auth token for AI SDK test');
const openai = createOpenAI({
apiKey,
baseURL: `${config.api_url}/puterai/openai/v1`,
});
const result = await streamText({
model: openai.chat('claude-haiku-4-5'),
messages: [
{
role: 'user',
content: 'Use the calculate tool to compute 2 + 2.',
},
],
tools: {
calculate: tool({
description: 'Perform a mathematical calculation',
inputSchema: jsonSchema({
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Mathematical expression to evaluate',
},
},
required: ['expression'],
}),
execute: async ({ expression }) => {
if ( ! expression ) return { expression, result: null };
const resultValue = Function(`"use strict"; return (${expression});`)();
return { expression, result: resultValue };
},
}),
},
toolChoice: { type: 'tool', toolName: 'calculate' },
stopWhen: stepCountIs(2),
});
const text = await result.text;
expect(text).toBeTruthy();
}, 20000);
it('accepts OpenAI tool result format (non-streaming)', async () => {
const config = loadConfig();
if ( ! config.do_expensive_ai_tests ) return;
const messages = [
{
role: 'user',
content: 'What is the weather in Seattle, WA and what is 2 + 2?',
},
];
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get the current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and state, e.g. San Francisco, CA',
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature unit',
},
},
required: ['location'],
},
},
},
{
type: 'function',
function: {
name: 'calculate',
description: 'Perform a mathematical calculation',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Mathematical expression to evaluate',
},
},
required: ['expression'],
},
},
},
];
const first = await postChat({
model: 'claude-haiku-4-5',
messages,
tools,
tool_choice: 'auto',
});
expect(first.response.status).toBe(200);
const firstJson = await first.response.json();
const toolCalls = firstJson?.choices?.[0]?.message?.tool_calls ?? [];
if ( ! toolCalls.length ) {
// If the model does not call tools, the test is inconclusive but should not fail.
return;
}
const toolResults = toolCalls.map((toolCall: any) => {
if ( toolCall.function?.name === 'get_weather' ) {
return {
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({
location: 'Seattle, WA',
temperature: 79,
unit: 'fahrenheit',
}),
};
}
if ( toolCall.function?.name === 'calculate' ) {
return {
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({ expression: '2 + 2', result: 4 }),
};
}
return {
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({ error: 'Unknown tool' }),
};
});
const followup = await postChat({
model: 'claude-haiku-4-5',
messages: [
...messages,
{ role: 'assistant', tool_calls: toolCalls },
...toolResults,
],
});
expect(followup.response.status).toBe(200);
const followupJson = await followup.response.json();
expect(followupJson?.choices?.[0]?.message).toBeTruthy();
}, 20000);
it('streams and returns a well-formed SSE response', async () => {
const config = loadConfig();
if ( ! config.do_expensive_ai_tests ) return;
const { response } = await postChat({
model: 'claude-haiku-4-5',
messages: [
{
role: 'user',
content: 'What is 2 + 2?',
},
],
stream: true,
});
expect(response.status).toBe(200);
const reader = response.body?.getReader();
expect(reader).toBeTruthy();
if ( ! reader ) return;
const decoder = new TextDecoder();
let sawDone = false;
let sawData = false;
let buffer = '';
while ( true ) {
const { value, done } = await reader.read();
if ( done ) break;
buffer += decoder.decode(value, { stream: true });
if ( buffer.includes('data:') ) sawData = true;
if ( buffer.includes('data: [DONE]') ) {
sawDone = true;
break;
}
}
expect(sawData).toBe(true);
expect(sawDone).toBe(true);
}, 20000);
});