From 2451c35f9d4071f00d972ce84a673ea99f4d881e Mon Sep 17 00:00:00 2001 From: hotwa Date: Sun, 22 Mar 2026 14:00:59 +0800 Subject: [PATCH] feat: add bidirectional tool_calls normalization - Add request-normalizer.ts to fix malformed tool_calls from clients - Ensure 'id' field exists (auto-generate if missing) - Ensure 'type: "function"' field exists - Convert 'arguments' string to object (Ollama expects object, not string) - Update response-rewriter.ts for Ollama native tool_calls - Add missing 'type' field to tool_calls - Remove Ollama-specific 'function.index' field - Fix types/ollama.ts: arguments should be object, not string This enables multi-turn conversations with tool_calls history from OpenClaw to work correctly with Ollama API. Co-Authored-By: Claude Opus 4.6 --- src/proxy/forward.ts | 5 +++ src/proxy/request-normalizer.ts | 71 +++++++++++++++++++++++++++++++++ src/proxy/response-rewriter.ts | 25 +++++++----- src/types/ollama.ts | 2 +- 4 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 src/proxy/request-normalizer.ts diff --git a/src/proxy/forward.ts b/src/proxy/forward.ts index 25305f5..7ddb5a3 100755 --- a/src/proxy/forward.ts +++ b/src/proxy/forward.ts @@ -1,5 +1,6 @@ import { config } from '../config'; import { rewriteResponse } from './response-rewriter'; +import { normalizeRequest } from './request-normalizer'; import { logger } from '../utils/logger'; export async function forwardChatRequest(requestBody: any): Promise { @@ -11,7 +12,11 @@ export async function forwardChatRequest(requestBody: any): Promise { requestBody.model = config.defaultModel; } + // Normalize request (fix tool_calls format issues) + requestBody = normalizeRequest(requestBody); + logger.info(`Forwarding chat request to ${targetEndpoint} for model: ${requestBody.model}`); + logger.info(`Request body: ${JSON.stringify(requestBody, null, 2)}`); const options: RequestInit = { method: 'POST', diff --git a/src/proxy/request-normalizer.ts b/src/proxy/request-normalizer.ts new file mode 100644 index 0000000..6cb08ca --- /dev/null +++ b/src/proxy/request-normalizer.ts @@ -0,0 +1,71 @@ +import { logger } from '../utils/logger'; + +/** + * Normalizes tool_calls in messages to match Ollama API expectations. + * Fixes common issues: + * - Missing 'id' field -> generate one + * - Missing 'type' field -> add 'function' + * - 'arguments' is object -> stringify to JSON string + */ +export function normalizeRequest(requestBody: any): any { + if (!requestBody.messages || !Array.isArray(requestBody.messages)) { + return requestBody; + } + + let fixedCount = 0; + + requestBody.messages = requestBody.messages.map((msg: any) => { + if (!msg.tool_calls || !Array.isArray(msg.tool_calls)) { + return msg; + } + + msg.tool_calls = msg.tool_calls.map((call: any, index: number) => { + const fixed: any = { ...call }; + + // Ensure 'id' exists + if (!fixed.id) { + fixed.id = `call_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`; + fixedCount++; + } + + // Ensure 'type' exists + if (!fixed.type) { + fixed.type = 'function'; + fixedCount++; + } + + // Ensure 'function' exists and is properly formatted + if (fixed.function) { + // Ensure 'arguments' is a JSON object (Ollama expects object, not string!) + if (fixed.function.arguments !== undefined) { + if (typeof fixed.function.arguments === 'string') { + // It's a string, parse it to object + try { + fixed.function.arguments = JSON.parse(fixed.function.arguments); + fixedCount++; + } catch (e) { + logger.error('Failed to parse arguments string:', fixed.function.arguments); + fixed.function.arguments = {}; + fixedCount++; + } + } + // If it's already an object, keep it as is + } else { + // arguments missing, add empty object + fixed.function.arguments = {}; + fixedCount++; + } + } + + return fixed; + }); + + return msg; + }); + + if (fixedCount > 0) { + logger.info(`Normalized ${fixedCount} tool_calls issues in request`); + } + + return requestBody; +} diff --git a/src/proxy/response-rewriter.ts b/src/proxy/response-rewriter.ts index 574b8c3..d53ecbf 100755 --- a/src/proxy/response-rewriter.ts +++ b/src/proxy/response-rewriter.ts @@ -12,8 +12,20 @@ export function rewriteResponse(response: OllamaChatResponse): OllamaChatRespons return response; } - // If the response already has tool_calls, do nothing + // If the response already has tool_calls, normalize them (add missing fields) if (response.message.tool_calls && response.message.tool_calls.length > 0) { + response.message.tool_calls = response.message.tool_calls.map((call: any) => { + const normalized: any = { ...call }; + // Ensure 'type' field exists + if (!normalized.type) { + normalized.type = 'function'; + } + // Remove Ollama-specific 'function.index' if present (not part of OpenAI spec) + if (normalized.function && normalized.function.index !== undefined) { + delete normalized.function.index; + } + return normalized; + }); return response; } @@ -30,20 +42,15 @@ export function rewriteResponse(response: OllamaChatResponse): OllamaChatRespons // Construct standard tool_calls const standardToolCalls: ToolCall[] = parsedCalls.map((call, index) => { - // Ensure arguments are correctly stringified as expected by standard OpenAI/Ollama APIs - let argumentsString = '{}'; - try { - argumentsString = JSON.stringify(call.args); - } catch (e) { - logger.error('Failed to stringify arguments for tool call', call.args); - } + // Ollama expects arguments as a JSON object, not a string! + const argsObject = call.args || {}; return { id: `call_${Date.now()}_${index}`, type: 'function', function: { name: call.name, - arguments: argumentsString, + arguments: argsObject, // Object, not stringified } }; }); diff --git a/src/types/ollama.ts b/src/types/ollama.ts index a7d7718..8c282be 100755 --- a/src/types/ollama.ts +++ b/src/types/ollama.ts @@ -9,7 +9,7 @@ export interface ToolCall { type: 'function'; function: { name: string; - arguments: string; // JSON string or directly object in some variations (but usually stringified JSON) + arguments: Record; // Ollama expects object, not string }; }