2025-03-11 17:30:50 +00:00
|
|
|
import options from '../../options.js';
|
|
|
|
import { BaseAIService } from '../base_ai_service.js';
|
2025-03-28 22:50:15 +00:00
|
|
|
import type { Message, ChatCompletionOptions, ChatResponse } from '../ai_interface.js';
|
|
|
|
import sanitizeHtml from 'sanitize-html';
|
|
|
|
import { OllamaMessageFormatter } from '../formatters/ollama_formatter.js';
|
2025-04-06 20:50:08 +00:00
|
|
|
import log from '../../log.js';
|
|
|
|
import type { ToolCall } from '../tools/tool_interfaces.js';
|
|
|
|
import toolRegistry from '../tools/tool_registry.js';
|
|
|
|
|
|
|
|
interface OllamaFunctionArguments {
|
|
|
|
[key: string]: any;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface OllamaFunctionCall {
|
|
|
|
function: {
|
|
|
|
name: string;
|
|
|
|
arguments: OllamaFunctionArguments | string;
|
|
|
|
};
|
|
|
|
id?: string;
|
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
|
2025-03-28 21:47:28 +00:00
|
|
|
interface OllamaMessage {
|
|
|
|
role: string;
|
|
|
|
content: string;
|
2025-04-06 20:50:08 +00:00
|
|
|
tool_calls?: OllamaFunctionCall[];
|
2025-03-28 21:47:28 +00:00
|
|
|
}
|
|
|
|
|
2025-03-28 22:50:15 +00:00
|
|
|
interface OllamaResponse {
|
|
|
|
model: string;
|
|
|
|
created_at: string;
|
|
|
|
message: OllamaMessage;
|
|
|
|
done: boolean;
|
2025-04-06 20:50:08 +00:00
|
|
|
done_reason?: string;
|
2025-03-28 22:50:15 +00:00
|
|
|
total_duration: number;
|
|
|
|
load_duration: number;
|
|
|
|
prompt_eval_count: number;
|
|
|
|
prompt_eval_duration: number;
|
|
|
|
eval_count: number;
|
|
|
|
eval_duration: number;
|
|
|
|
}
|
|
|
|
|
2025-03-02 19:39:10 -08:00
|
|
|
export class OllamaService extends BaseAIService {
|
2025-03-28 22:50:15 +00:00
|
|
|
private formatter: OllamaMessageFormatter;
|
|
|
|
|
2025-03-02 19:39:10 -08:00
|
|
|
constructor() {
|
|
|
|
super('Ollama');
|
2025-03-28 22:50:15 +00:00
|
|
|
this.formatter = new OllamaMessageFormatter();
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
isAvailable(): boolean {
|
2025-03-28 22:50:15 +00:00
|
|
|
return super.isAvailable() && !!options.getOption('ollamaBaseUrl');
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async generateChatCompletion(messages: Message[], opts: ChatCompletionOptions = {}): Promise<ChatResponse> {
|
|
|
|
if (!this.isAvailable()) {
|
2025-03-28 22:50:15 +00:00
|
|
|
throw new Error('Ollama service is not available. Check API URL in settings.');
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
|
2025-03-28 22:50:15 +00:00
|
|
|
const apiBase = options.getOption('ollamaBaseUrl');
|
|
|
|
const model = opts.model || options.getOption('ollamaDefaultModel') || 'llama3';
|
2025-03-02 19:39:10 -08:00
|
|
|
const temperature = opts.temperature !== undefined
|
|
|
|
? opts.temperature
|
|
|
|
: parseFloat(options.getOption('aiTemperature') || '0.7');
|
|
|
|
|
|
|
|
const systemPrompt = this.getSystemPrompt(opts.systemPrompt || options.getOption('aiSystemPrompt'));
|
|
|
|
|
|
|
|
try {
|
2025-04-01 21:42:09 +00:00
|
|
|
// Determine whether to use the formatter or send messages directly
|
|
|
|
let messagesToSend: Message[];
|
2025-03-28 22:50:15 +00:00
|
|
|
|
2025-04-01 21:42:09 +00:00
|
|
|
if (opts.bypassFormatter) {
|
|
|
|
// Bypass the formatter entirely - use messages as is
|
|
|
|
messagesToSend = [...messages];
|
2025-04-06 20:50:08 +00:00
|
|
|
log.info(`Bypassing formatter for Ollama request with ${messages.length} messages`);
|
2025-04-01 21:42:09 +00:00
|
|
|
} else {
|
|
|
|
// Use the formatter to prepare messages
|
|
|
|
messagesToSend = this.formatter.formatMessages(
|
|
|
|
messages,
|
|
|
|
systemPrompt,
|
|
|
|
undefined, // context
|
|
|
|
opts.preserveSystemPrompt
|
|
|
|
);
|
2025-04-06 20:50:08 +00:00
|
|
|
log.info(`Sending to Ollama with formatted messages: ${messagesToSend.length}`);
|
2025-04-01 21:42:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Check if this is a request that expects JSON response
|
|
|
|
const expectsJsonResponse = opts.expectsJsonResponse || false;
|
|
|
|
|
2025-04-06 20:50:08 +00:00
|
|
|
// Build request body
|
|
|
|
const requestBody: any = {
|
|
|
|
model,
|
|
|
|
messages: messagesToSend,
|
|
|
|
options: {
|
|
|
|
temperature,
|
|
|
|
// Add response_format for requests that expect JSON
|
|
|
|
...(expectsJsonResponse ? { response_format: { type: "json_object" } } : {})
|
|
|
|
},
|
|
|
|
stream: false
|
|
|
|
};
|
|
|
|
|
|
|
|
// Add tools if enabled - put them at the top level for Ollama
|
|
|
|
if (opts.enableTools !== false) {
|
|
|
|
// Get tools from registry if not provided in options
|
|
|
|
if (!opts.tools || opts.tools.length === 0) {
|
|
|
|
try {
|
|
|
|
// Get tool definitions from registry
|
|
|
|
const tools = toolRegistry.getAllToolDefinitions();
|
|
|
|
requestBody.tools = tools;
|
|
|
|
log.info(`Adding ${tools.length} tools to request`);
|
|
|
|
|
|
|
|
// If no tools found, reinitialize
|
|
|
|
if (tools.length === 0) {
|
|
|
|
log.info('No tools found in registry, re-initializing...');
|
|
|
|
try {
|
|
|
|
const toolInitializer = await import('../tools/tool_initializer.js');
|
|
|
|
await toolInitializer.default.initializeTools();
|
|
|
|
|
|
|
|
// Try again
|
|
|
|
requestBody.tools = toolRegistry.getAllToolDefinitions();
|
|
|
|
log.info(`After re-initialization: ${requestBody.tools.length} tools available`);
|
|
|
|
} catch (err: any) {
|
|
|
|
log.error(`Failed to re-initialize tools: ${err.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error: any) {
|
|
|
|
log.error(`Error getting tools: ${error.message || String(error)}`);
|
|
|
|
// Create default empty tools array if we couldn't load the tools
|
|
|
|
requestBody.tools = [];
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
requestBody.tools = opts.tools;
|
|
|
|
}
|
|
|
|
log.info(`Adding ${requestBody.tools.length} tools to Ollama request`);
|
|
|
|
} else {
|
|
|
|
log.info('Tools are explicitly disabled for this request');
|
2025-04-01 21:42:09 +00:00
|
|
|
}
|
2025-03-28 22:50:15 +00:00
|
|
|
|
2025-04-06 20:50:08 +00:00
|
|
|
// Log key request details
|
|
|
|
log.info(`========== OLLAMA API REQUEST ==========`);
|
|
|
|
log.info(`Model: ${requestBody.model}, Messages: ${requestBody.messages.length}, Tools: ${requestBody.tools ? requestBody.tools.length : 0}`);
|
|
|
|
log.info(`Temperature: ${temperature}, Stream: ${requestBody.stream}, JSON response expected: ${expectsJsonResponse}`);
|
|
|
|
|
|
|
|
// Check message structure and log detailed information about each message
|
|
|
|
requestBody.messages.forEach((msg: any, index: number) => {
|
|
|
|
const keys = Object.keys(msg);
|
|
|
|
log.info(`Message ${index}, Role: ${msg.role}, Keys: ${keys.join(', ')}`);
|
|
|
|
|
|
|
|
// Log message content preview
|
|
|
|
if (msg.content && typeof msg.content === 'string') {
|
|
|
|
const contentPreview = msg.content.length > 200
|
|
|
|
? `${msg.content.substring(0, 200)}...`
|
|
|
|
: msg.content;
|
|
|
|
log.info(`Message ${index} content: ${contentPreview}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Log tool-related details
|
|
|
|
if (keys.includes('tool_calls')) {
|
|
|
|
log.info(`Message ${index} has ${msg.tool_calls.length} tool calls:`);
|
|
|
|
msg.tool_calls.forEach((call: any, callIdx: number) => {
|
|
|
|
log.info(` Tool call ${callIdx}: ${call.function?.name || 'unknown'}, ID: ${call.id || 'unspecified'}`);
|
|
|
|
if (call.function?.arguments) {
|
|
|
|
const argsPreview = typeof call.function.arguments === 'string'
|
|
|
|
? call.function.arguments.substring(0, 100)
|
|
|
|
: JSON.stringify(call.function.arguments).substring(0, 100);
|
|
|
|
log.info(` Arguments: ${argsPreview}...`);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (keys.includes('tool_call_id')) {
|
|
|
|
log.info(`Message ${index} is a tool response for tool call ID: ${msg.tool_call_id}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (keys.includes('name') && msg.role === 'tool') {
|
|
|
|
log.info(`Message ${index} is from tool: ${msg.name}`);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Log tool definitions
|
|
|
|
if (requestBody.tools && requestBody.tools.length > 0) {
|
|
|
|
log.info(`Sending ${requestBody.tools.length} tool definitions:`);
|
|
|
|
requestBody.tools.forEach((tool: any, toolIdx: number) => {
|
|
|
|
log.info(` Tool ${toolIdx}: ${tool.function?.name || 'unnamed'}`);
|
|
|
|
if (tool.function?.description) {
|
|
|
|
log.info(` Description: ${tool.function.description.substring(0, 100)}...`);
|
|
|
|
}
|
|
|
|
if (tool.function?.parameters) {
|
|
|
|
const paramNames = tool.function.parameters.properties
|
|
|
|
? Object.keys(tool.function.parameters.properties)
|
|
|
|
: [];
|
|
|
|
log.info(` Parameters: ${paramNames.join(', ')}`);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Log full request body (this will create large logs but is helpful for debugging)
|
|
|
|
const requestStr = JSON.stringify(requestBody);
|
|
|
|
log.info(`Full Ollama request (truncated): ${requestStr.substring(0, 1000)}...`);
|
|
|
|
log.info(`========== END OLLAMA REQUEST ==========`);
|
|
|
|
|
|
|
|
// Make API request
|
2025-03-28 22:50:15 +00:00
|
|
|
const response = await fetch(`${apiBase}/api/chat`, {
|
|
|
|
method: 'POST',
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
},
|
2025-04-06 20:50:08 +00:00
|
|
|
body: JSON.stringify(requestBody)
|
2025-03-28 21:47:28 +00:00
|
|
|
});
|
2025-03-28 22:29:33 +00:00
|
|
|
|
2025-03-28 22:50:15 +00:00
|
|
|
if (!response.ok) {
|
|
|
|
const errorBody = await response.text();
|
2025-04-06 20:50:08 +00:00
|
|
|
log.error(`Ollama API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
2025-03-28 22:50:15 +00:00
|
|
|
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
2025-03-28 22:29:33 +00:00
|
|
|
}
|
|
|
|
|
2025-03-28 22:50:15 +00:00
|
|
|
const data: OllamaResponse = await response.json();
|
2025-04-06 20:50:08 +00:00
|
|
|
|
|
|
|
// Log response details
|
|
|
|
log.info(`========== OLLAMA API RESPONSE ==========`);
|
|
|
|
log.info(`Model: ${data.model}, Content length: ${data.message.content.length} chars`);
|
|
|
|
log.info(`Tokens: ${data.prompt_eval_count} prompt, ${data.eval_count} completion, ${data.prompt_eval_count + data.eval_count} total`);
|
|
|
|
log.info(`Duration: ${data.total_duration}ns total, ${data.prompt_eval_duration}ns prompt, ${data.eval_duration}ns completion`);
|
|
|
|
log.info(`Done: ${data.done}, Reason: ${data.done_reason || 'not specified'}`);
|
|
|
|
|
|
|
|
// Log content preview
|
|
|
|
const contentPreview = data.message.content.length > 300
|
|
|
|
? `${data.message.content.substring(0, 300)}...`
|
|
|
|
: data.message.content;
|
|
|
|
log.info(`Response content: ${contentPreview}`);
|
|
|
|
|
|
|
|
// Handle the response and extract tool calls if present
|
|
|
|
const chatResponse: ChatResponse = {
|
2025-03-28 22:50:15 +00:00
|
|
|
text: data.message.content,
|
|
|
|
model: data.model,
|
|
|
|
provider: this.getName(),
|
|
|
|
usage: {
|
|
|
|
promptTokens: data.prompt_eval_count,
|
|
|
|
completionTokens: data.eval_count,
|
|
|
|
totalTokens: data.prompt_eval_count + data.eval_count
|
|
|
|
}
|
|
|
|
};
|
2025-04-06 20:50:08 +00:00
|
|
|
|
|
|
|
// Add tool calls if present
|
|
|
|
if (data.message.tool_calls && data.message.tool_calls.length > 0) {
|
|
|
|
log.info(`Ollama response includes ${data.message.tool_calls.length} tool calls`);
|
|
|
|
|
|
|
|
// Log detailed information about each tool call
|
|
|
|
const transformedToolCalls: ToolCall[] = [];
|
|
|
|
|
|
|
|
// Log detailed information about the tool calls in the response
|
|
|
|
log.info(`========== OLLAMA TOOL CALLS IN RESPONSE ==========`);
|
|
|
|
data.message.tool_calls.forEach((toolCall, index) => {
|
|
|
|
log.info(`Tool call ${index + 1}:`);
|
|
|
|
log.info(` Name: ${toolCall.function?.name || 'unknown'}`);
|
|
|
|
log.info(` ID: ${toolCall.id || `auto-${index + 1}`}`);
|
|
|
|
|
|
|
|
// Generate a unique ID if none is provided
|
|
|
|
const id = toolCall.id || `tool-call-${Date.now()}-${index}`;
|
|
|
|
|
|
|
|
// Handle arguments based on their type
|
|
|
|
let processedArguments: Record<string, any> | string;
|
|
|
|
|
|
|
|
if (typeof toolCall.function.arguments === 'string') {
|
|
|
|
// Log raw string arguments in full for debugging
|
|
|
|
log.info(` Raw string arguments: ${toolCall.function.arguments}`);
|
|
|
|
|
|
|
|
// Try to parse JSON string arguments
|
|
|
|
try {
|
|
|
|
processedArguments = JSON.parse(toolCall.function.arguments);
|
|
|
|
log.info(` Successfully parsed arguments to object with keys: ${Object.keys(processedArguments).join(', ')}`);
|
|
|
|
log.info(` Parsed argument values:`);
|
|
|
|
Object.entries(processedArguments).forEach(([key, value]) => {
|
|
|
|
const valuePreview = typeof value === 'string'
|
|
|
|
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
|
|
|
|
: JSON.stringify(value);
|
|
|
|
log.info(` ${key}: ${valuePreview}`);
|
|
|
|
});
|
|
|
|
} catch (e) {
|
|
|
|
// If parsing fails, keep as string and log the error
|
|
|
|
processedArguments = toolCall.function.arguments;
|
|
|
|
log.info(` Could not parse arguments as JSON: ${e.message}`);
|
|
|
|
log.info(` Keeping as string: ${processedArguments.substring(0, 200)}${processedArguments.length > 200 ? '...' : ''}`);
|
|
|
|
|
|
|
|
// Try to clean and parse again with more aggressive methods
|
|
|
|
try {
|
|
|
|
const cleaned = toolCall.function.arguments
|
|
|
|
.replace(/^['"]|['"]$/g, '') // Remove surrounding quotes
|
|
|
|
.replace(/\\"/g, '"') // Replace escaped quotes
|
|
|
|
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
|
|
|
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
|
|
|
|
|
|
|
log.info(` Attempting to parse cleaned argument: ${cleaned}`);
|
|
|
|
const reparseArg = JSON.parse(cleaned);
|
|
|
|
log.info(` Successfully parsed cleaned argument with keys: ${Object.keys(reparseArg).join(', ')}`);
|
|
|
|
} catch (cleanErr) {
|
|
|
|
log.info(` Failed to parse cleaned arguments: ${cleanErr.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If it's already an object, use it directly and log details
|
|
|
|
processedArguments = toolCall.function.arguments;
|
|
|
|
log.info(` Object arguments with keys: ${Object.keys(processedArguments).join(', ')}`);
|
|
|
|
log.info(` Argument values:`);
|
|
|
|
Object.entries(processedArguments).forEach(([key, value]) => {
|
|
|
|
const valuePreview = typeof value === 'string'
|
|
|
|
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
|
|
|
|
: JSON.stringify(value);
|
|
|
|
log.info(` ${key}: ${valuePreview}`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Convert to our standard ToolCall format
|
|
|
|
transformedToolCalls.push({
|
|
|
|
id,
|
|
|
|
type: 'function',
|
|
|
|
function: {
|
|
|
|
name: toolCall.function.name,
|
|
|
|
arguments: processedArguments
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Add transformed tool calls to response
|
|
|
|
chatResponse.tool_calls = transformedToolCalls;
|
|
|
|
log.info(`Transformed ${transformedToolCalls.length} tool calls for execution`);
|
|
|
|
log.info(`========== END OLLAMA TOOL CALLS ==========`);
|
|
|
|
}
|
|
|
|
|
|
|
|
log.info(`========== END OLLAMA RESPONSE ==========`);
|
|
|
|
return chatResponse;
|
|
|
|
} catch (error: any) {
|
|
|
|
log.error(`Ollama service error: ${error.message || String(error)}`);
|
2025-03-28 22:50:15 +00:00
|
|
|
throw error;
|
2025-03-28 21:47:28 +00:00
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
}
|