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:
@@ -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',
|
||||
|
||||
71
src/proxy/request-normalizer.ts
Normal file
71
src/proxy/request-normalizer.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user