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 <noreply@anthropic.com>
This commit is contained in:
hotwa
2026-03-22 14:00:59 +08:00
parent ba7d42da13
commit 2451c35f9d
4 changed files with 93 additions and 10 deletions

View File

@@ -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<any> {
@@ -11,7 +12,11 @@ export async function forwardChatRequest(requestBody: any): Promise<any> {
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',

View File

@@ -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;
}

View File

@@ -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
}
};
});

View File

@@ -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<string, any>; // Ollama expects object, not string
};
}