From 11563205468f3c3e13d541bee98f1105b342f14a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 20 Jan 2026 02:39:28 +0000 Subject: [PATCH] feat(provider.ollama): add native tool calling support for Ollama API - Add IOllamaTool and IOllamaToolCall types for native function calling - Add think parameter to IOllamaModelOptions for reasoning models (GPT-OSS, QwQ) - Add tools parameter to IOllamaChatOptions - Add toolCalls to response interfaces (IOllamaStreamChunk, IOllamaChatResponse) - Update chat(), chatStreamResponse(), collectStreamResponse(), chatWithOptions() to support native tools - Parse tool_calls from Ollama API responses - Add support for tool message role in conversation history --- ts/provider.ollama.ts | 154 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/ts/provider.ollama.ts b/ts/provider.ollama.ts index 0a4a35a..2a58bdf 100644 --- a/ts/provider.ollama.ts +++ b/ts/provider.ollama.ts @@ -26,6 +26,39 @@ export interface IOllamaModelOptions { num_predict?: number; // Max tokens to predict stop?: string[]; // Stop sequences seed?: number; // Random seed for reproducibility + think?: boolean; // Enable thinking/reasoning mode (for GPT-OSS, QwQ, etc.) +} + +/** + * JSON Schema tool definition for Ollama native tool calling + * @see https://docs.ollama.com/capabilities/tool-calling + */ +export interface IOllamaTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +} + +/** + * Tool call returned by model in native tool calling mode + */ +export interface IOllamaToolCall { + function: { + name: string; + arguments: Record; + index?: number; + }; } export interface IOllamaProviderOptions { @@ -43,6 +76,7 @@ export interface IOllamaChatOptions extends ChatOptions { options?: IOllamaModelOptions; // Per-request model options timeout?: number; // Per-request timeout in ms model?: string; // Per-request model override + tools?: IOllamaTool[]; // Available tools for native function calling // images is inherited from ChatOptions } @@ -52,6 +86,7 @@ export interface IOllamaChatOptions extends ChatOptions { export interface IOllamaStreamChunk { content: string; thinking?: string; // For models with extended thinking + toolCalls?: IOllamaToolCall[]; // Tool calls in streaming mode done: boolean; stats?: { totalDuration?: number; @@ -64,6 +99,7 @@ export interface IOllamaStreamChunk { */ export interface IOllamaChatResponse extends ChatResponse { thinking?: string; + toolCalls?: IOllamaToolCall[]; // Tool calls from model (native tool calling) stats?: { totalDuration?: number; evalCount?: number; @@ -233,18 +269,26 @@ export class OllamaProvider extends MultiModalModel { userMessage, ]; + // Build request body - include think parameter if set + const requestBody: Record = { + model: this.model, + messages: messages, + stream: false, + options: this.defaultOptions, + }; + + // Add think parameter for reasoning models (GPT-OSS, QwQ, etc.) + if (this.defaultOptions.think !== undefined) { + requestBody.think = this.defaultOptions.think; + } + // Make API call to Ollama with defaultOptions and timeout const response = await fetch(`${this.baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - model: this.model, - messages: messages, - stream: false, - options: this.defaultOptions, - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(this.defaultTimeout), }); @@ -331,15 +375,28 @@ export class OllamaProvider extends MultiModalModel { userMessage, ]; + // Build request body with optional tools and think parameters + const requestBody: Record = { + model, + messages, + stream: true, + options: modelOptions, + }; + + // Add think parameter for reasoning models (GPT-OSS, QwQ, etc.) + if (modelOptions.think !== undefined) { + requestBody.think = modelOptions.think; + } + + // Add tools for native function calling + if (optionsArg.tools && optionsArg.tools.length > 0) { + requestBody.tools = optionsArg.tools; + } + const response = await fetch(`${this.baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages, - stream: true, - options: modelOptions, - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(timeout), }); @@ -364,9 +421,25 @@ export class OllamaProvider extends MultiModalModel { if (!line.trim()) continue; try { const json = JSON.parse(line); + + // Parse tool_calls from response + let toolCalls: IOllamaToolCall[] | undefined; + if (json.message?.tool_calls && Array.isArray(json.message.tool_calls)) { + toolCalls = json.message.tool_calls.map((tc: any) => ({ + function: { + name: tc.function?.name || '', + arguments: typeof tc.function?.arguments === 'string' + ? JSON.parse(tc.function.arguments) + : tc.function?.arguments || {}, + index: tc.index, + }, + })); + } + yield { content: json.message?.content || '', thinking: json.message?.thinking, + toolCalls, done: json.done || false, stats: json.done ? { totalDuration: json.total_duration, @@ -393,11 +466,13 @@ export class OllamaProvider extends MultiModalModel { const stream = await this.chatStreamResponse(optionsArg); let content = ''; let thinking = ''; + let toolCalls: IOllamaToolCall[] = []; let stats: IOllamaChatResponse['stats']; for await (const chunk of stream) { if (chunk.content) content += chunk.content; if (chunk.thinking) thinking += chunk.thinking; + if (chunk.toolCalls) toolCalls = toolCalls.concat(chunk.toolCalls); if (chunk.stats) stats = chunk.stats; if (onChunk) onChunk(chunk); } @@ -406,6 +481,7 @@ export class OllamaProvider extends MultiModalModel { role: 'assistant' as const, message: content, thinking: thinking || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, stats, }; } @@ -418,8 +494,17 @@ export class OllamaProvider extends MultiModalModel { const timeout = optionsArg.timeout || this.defaultTimeout; const modelOptions = { ...this.defaultOptions, ...optionsArg.options }; - // Format history messages with optional images and reasoning + // Format history messages with optional images, reasoning, and tool role const historyMessages = optionsArg.messageHistory.map((msg) => { + // Handle tool result messages + if ((msg as any).role === 'tool') { + return { + role: 'tool', + content: msg.content, + tool_name: (msg as any).toolName, + }; + } + const formatted: { role: string; content: string; images?: string[]; reasoning?: string } = { role: msg.role, content: msg.content, @@ -448,15 +533,28 @@ export class OllamaProvider extends MultiModalModel { userMessage, ]; + // Build request body with optional tools and think parameters + const requestBody: Record = { + model, + messages, + stream: false, + options: modelOptions, + }; + + // Add think parameter for reasoning models (GPT-OSS, QwQ, etc.) + if (modelOptions.think !== undefined) { + requestBody.think = modelOptions.think; + } + + // Add tools for native function calling + if (optionsArg.tools && optionsArg.tools.length > 0) { + requestBody.tools = optionsArg.tools; + } + const response = await fetch(`${this.baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages, - stream: false, - options: modelOptions, - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(timeout), }); @@ -465,10 +563,26 @@ export class OllamaProvider extends MultiModalModel { } const result = await response.json(); + + // Parse tool_calls from response + let toolCalls: IOllamaToolCall[] | undefined; + if (result.message?.tool_calls && Array.isArray(result.message.tool_calls)) { + toolCalls = result.message.tool_calls.map((tc: any) => ({ + function: { + name: tc.function?.name || '', + arguments: typeof tc.function?.arguments === 'string' + ? JSON.parse(tc.function.arguments) + : tc.function?.arguments || {}, + index: tc.index, + }, + })); + } + return { role: 'assistant' as const, - message: result.message.content, + message: result.message.content || '', thinking: result.message.thinking, + toolCalls, stats: { totalDuration: result.total_duration, evalCount: result.eval_count,