diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js index dfd6aad..8a68ab1 100644 --- a/internal/js/chat-stream/vercel_stream_impl.js +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -205,14 +205,14 @@ async function handleVercelStream(req, res, rawBody, payload) { if (detected.length > 0 && !toolCallsDoneEmitted) { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs, payload.tools) }); } else if (toolSieveEnabled) { const tailEvents = flushToolSieve(toolSieveState, toolNames); for (const evt of tailEvents) { if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } @@ -352,14 +352,14 @@ async function handleVercelStream(req, res, rawBody, payload) { const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs); if (formatted.length > 0) { toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatted }); + sendDeltaFrame({ tool_calls: formatted }); } continue; } if (evt.type === 'tool_calls') { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } diff --git a/internal/js/helpers/stream-tool-sieve/format.js b/internal/js/helpers/stream-tool-sieve/format.js index 74da078..88d7271 100644 --- a/internal/js/helpers/stream-tool-sieve/format.js +++ b/internal/js/helpers/stream-tool-sieve/format.js @@ -2,11 +2,12 @@ const crypto = require('crypto'); -function formatOpenAIStreamToolCalls(calls, idStore) { +function formatOpenAIStreamToolCalls(calls, idStore, toolsRaw) { if (!Array.isArray(calls) || calls.length === 0) { return []; } - return calls.map((c, idx) => ({ + const normalized = normalizeParsedToolCallsForSchemas(calls, toolsRaw); + return normalized.map((c, idx) => ({ index: idx, id: ensureStreamToolCallID(idStore, idx), type: 'function', @@ -17,6 +18,194 @@ function formatOpenAIStreamToolCalls(calls, idStore) { })); } +function normalizeParsedToolCallsForSchemas(calls, toolsRaw) { + if (!Array.isArray(calls) || calls.length === 0) { + return calls; + } + const schemas = buildToolSchemaIndex(toolsRaw); + if (!schemas) { + return calls; + } + let changedAny = false; + const out = calls.map((call) => { + const name = String(call && call.name || '').trim().toLowerCase(); + const schema = schemas[name]; + if (!schema || !call || !call.input || typeof call.input !== 'object' || Array.isArray(call.input)) { + return call; + } + const [normalized, changed] = normalizeToolValueWithSchema(call.input, schema); + if (!changed || !normalized || typeof normalized !== 'object' || Array.isArray(normalized)) { + return call; + } + changedAny = true; + return { ...call, input: normalized }; + }); + return changedAny ? out : calls; +} + +function buildToolSchemaIndex(toolsRaw) { + if (!Array.isArray(toolsRaw) || toolsRaw.length === 0) { + return null; + } + const out = {}; + for (const item of toolsRaw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue; + } + const [name, schema] = extractToolNameAndSchema(item); + if (!name || !schema || typeof schema !== 'object' || Array.isArray(schema)) { + continue; + } + out[name.toLowerCase()] = schema; + } + return Object.keys(out).length > 0 ? out : null; +} + +function extractToolNameAndSchema(tool) { + const fn = tool && typeof tool.function === 'object' && !Array.isArray(tool.function) ? tool.function : null; + const name = firstNonEmptyString(tool.name, fn && fn.name); + const schema = firstNonNil( + tool.parameters, + tool.input_schema, + tool.inputSchema, + tool.schema, + fn && fn.parameters, + fn && fn.input_schema, + fn && fn.inputSchema, + fn && fn.schema, + ); + return [name, schema]; +} + +function normalizeToolValueWithSchema(value, schema) { + if (value == null || !schema || typeof schema !== 'object' || Array.isArray(schema)) { + return [value, false]; + } + if (shouldCoerceSchemaToString(schema)) { + return stringifySchemaValue(value); + } + if (looksLikeObjectSchema(schema)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return [value, false]; + } + const properties = schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties) ? schema.properties : null; + const additional = schema.additionalProperties; + let changed = false; + const out = {}; + for (const [key, current] of Object.entries(value)) { + let next = current; + let fieldChanged = false; + if (properties && Object.prototype.hasOwnProperty.call(properties, key)) { + [next, fieldChanged] = normalizeToolValueWithSchema(current, properties[key]); + } else if (additional != null) { + [next, fieldChanged] = normalizeToolValueWithSchema(current, additional); + } + out[key] = next; + changed = changed || fieldChanged; + } + return changed ? [out, true] : [value, false]; + } + if (looksLikeArraySchema(schema)) { + if (!Array.isArray(value) || value.length === 0 || schema.items == null) { + return [value, false]; + } + let changed = false; + const out = value.map((item, idx) => { + const itemSchema = Array.isArray(schema.items) ? schema.items[idx] : schema.items; + if (itemSchema == null) { + return item; + } + const [next, itemChanged] = normalizeToolValueWithSchema(item, itemSchema); + changed = changed || itemChanged; + return next; + }); + return changed ? [out, true] : [value, false]; + } + return [value, false]; +} + +function shouldCoerceSchemaToString(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return false; + } + if (typeof schema.const === 'string') { + return true; + } + if (Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((item) => typeof item === 'string')) { + return true; + } + if (typeof schema.type === 'string') { + return schema.type.trim().toLowerCase() === 'string'; + } + if (Array.isArray(schema.type) && schema.type.length > 0) { + let hasString = false; + for (const item of schema.type) { + if (typeof item !== 'string') { + return false; + } + const typ = item.trim().toLowerCase(); + if (typ === 'string') { + hasString = true; + } else if (typ !== 'null') { + return false; + } + } + return hasString; + } + return false; +} + +function looksLikeObjectSchema(schema) { + return !!schema && typeof schema === 'object' && !Array.isArray(schema) && ( + (typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'object') || + (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) || + schema.additionalProperties != null + ); +} + +function looksLikeArraySchema(schema) { + return !!schema && typeof schema === 'object' && !Array.isArray(schema) && ( + (typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'array') || + schema.items != null + ); +} + +function stringifySchemaValue(value) { + if (value == null) { + return [value, false]; + } + if (typeof value === 'string') { + return [value, false]; + } + try { + return [JSON.stringify(value), true]; + } catch { + return [value, false]; + } +} + +function firstNonNil(...values) { + for (const value of values) { + if (value != null) { + return value; + } + } + return null; +} + +function firstNonEmptyString(...values) { + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + return ''; +} + function ensureStreamToolCallID(idStore, index) { if (!(idStore instanceof Map)) { return `call_${newCallID()}`; diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index d26b8ca..f8265f7 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -188,6 +188,30 @@ test('parseToolCalls treats single-item CDATA body as array', () => { assert.deepEqual(calls[0].input.todos, ['one']); }); +test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => { + const formatted = formatOpenAIStreamToolCalls([ + { name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } }, + ], new Map(), [ + { name: 'Write', inputSchema: { type: 'object', properties: { content: { type: 'string' }, taskId: { type: 'string' } } } }, + ]); + assert.equal(formatted.length, 1); + const args = JSON.parse(formatted[0].function.arguments); + assert.equal(args.content, '{"message":"hi"}'); + assert.equal(args.taskId, '1'); +}); + +test('formatOpenAIStreamToolCalls preserves arrays when schema says array', () => { + const todos = [{ content: 'x', status: 'pending', priority: 'high' }]; + const formatted = formatOpenAIStreamToolCalls([ + { name: 'todowrite', input: { todos } }, + ], new Map(), [ + { name: 'todowrite', inputSchema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } } } }, + ]); + assert.equal(formatted.length, 1); + const args = JSON.parse(formatted[0].function.arguments); + assert.deepEqual(args.todos, todos); +}); + test('parseToolCalls treats CDATA object fragment as object', () => { const fragment = ''; const payload = ``;