mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 08:30:39 +00:00
chat_completions tool call fixes (#2434)
* chat_completions tool call fixes * update chat completions test
This commit is contained in:
Generated
+138
-4
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(' ');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user