From 28cedec9de6d8e9dbf4af7ee427e059db05bc01a Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:10:27 -0800 Subject: [PATCH] chat_completions tool call fixes (#2434) * chat_completions tool call fixes * update chat completions test --- package-lock.json | 142 +++++++- package.json | 2 + .../puterai/openai/chat_completions.js | 9 +- .../src/services/ai/utils/FunctionCalling.js | 10 +- src/backend/src/services/ai/utils/Messages.js | 54 ++- tests/example-client-config.yaml | 1 + .../ai_chat_completions.test.ts | 314 ++++++++++++++++++ 7 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 tests/puterJsApiTests/ai_chat_completions.test.ts diff --git a/package-lock.json b/package-lock.json index 298df0e18..895d31d9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 6004cf5e8..59394b559 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/src/routers/puterai/openai/chat_completions.js b/src/backend/src/routers/puterai/openai/chat_completions.js index 22c554ddf..6f5901d46 100644 --- a/src/backend/src/routers/puterai/openai/chat_completions.js +++ b/src/backend/src/routers/puterai/openai/chat_completions.js @@ -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(); }); diff --git a/src/backend/src/services/ai/utils/FunctionCalling.js b/src/backend/src/services/ai/utils/FunctionCalling.js index a1a18c282..b4ea999b6 100644 --- a/src/backend/src/services/ai/utils/FunctionCalling.js +++ b/src/backend/src/services/ai/utils/FunctionCalling.js @@ -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); diff --git a/src/backend/src/services/ai/utils/Messages.js b/src/backend/src/services/ai/utils/Messages.js index 64cad9b86..43db30779 100644 --- a/src/backend/src/services/ai/utils/Messages.js +++ b/src/backend/src/services/ai/utils/Messages.js @@ -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(' '); -}; \ No newline at end of file +}; diff --git a/tests/example-client-config.yaml b/tests/example-client-config.yaml index 6e37bb720..41f88be17 100644 --- a/tests/example-client-config.yaml +++ b/tests/example-client-config.yaml @@ -2,6 +2,7 @@ api_url: http://api.puter.localhost:4100 frontend_url: http://puter.localhost:4100 username: admin auth_token: +do_expensive_ai_tests: false mountpoints: - path: / provider: puterfs diff --git a/tests/puterJsApiTests/ai_chat_completions.test.ts b/tests/puterJsApiTests/ai_chat_completions.test.ts new file mode 100644 index 000000000..bf6f105d2 --- /dev/null +++ b/tests/puterJsApiTests/ai_chat_completions.test.ts @@ -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 = { + '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); +});