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 = ``;