From 09add37423d70b809d5c28f3cde9fccd5a7e64e7 Mon Sep 17 00:00:00 2001 From: Eyal Toledano Date: Fri, 23 May 2025 20:20:39 -0400 Subject: [PATCH] feat(models): Add comprehensive Ollama model validation and interactive setup - Add 'Custom Ollama model' option to interactive setup (--setup) - Implement live validation against local Ollama instance via /api/tags - Support configurable Ollama endpoints from .taskmasterconfig - Add robust error handling for server connectivity and model existence - Enhance user experience with clear validation feedback - Support both MCP server and CLI interfaces --- .changeset/better-seas-sit.md | 15 ++++ .taskmasterconfig | 4 +- scripts/modules/commands.js | 109 ++++++++++++++++++++++++- scripts/modules/task-manager/models.js | 93 +++++++++++++++++++-- 4 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 .changeset/better-seas-sit.md diff --git a/.changeset/better-seas-sit.md b/.changeset/better-seas-sit.md new file mode 100644 index 00000000..3fd2e576 --- /dev/null +++ b/.changeset/better-seas-sit.md @@ -0,0 +1,15 @@ +--- +'task-master-ai': minor +--- + +Added comprehensive Ollama model validation and interactive setup support + +- **Interactive Setup Enhancement**: Added "Custom Ollama model" option to `task-master models --setup`, matching the existing OpenRouter functionality +- **Live Model Validation**: When setting Ollama models, Taskmaster now validates against the local Ollama instance by querying `/api/tags` endpoint +- **Configurable Endpoints**: Uses the `ollamaBaseUrl` from `.taskmasterconfig` (with role-specific `baseUrl` overrides supported) +- **Robust Error Handling**: + - Detects when Ollama server is not running and provides clear error messages + - Validates model existence and lists available alternatives when model not found + - Graceful fallback behavior for connection issues +- **Full Platform Support**: Both MCP server tools and CLI commands support the new validation +- **Improved User Experience**: Clear feedback during model validation with informative success/error messages diff --git a/.taskmasterconfig b/.taskmasterconfig index 0d0de0fd..f727bc0a 100644 --- a/.taskmasterconfig +++ b/.taskmasterconfig @@ -2,7 +2,7 @@ "models": { "main": { "provider": "anthropic", - "modelId": "claude-3-7-sonnet-20250219", + "modelId": "claude-sonnet-4-20250514", "maxTokens": 120000, "temperature": 0.2 }, @@ -14,7 +14,7 @@ }, "fallback": { "provider": "anthropic", - "modelId": "claude-3-5-sonnet-20241022", + "modelId": "claude-3-7-sonnet-20250219", "maxTokens": 8192, "temperature": 0.2 } diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index d6f5d3f3..99b5d98b 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -9,6 +9,7 @@ import chalk from 'chalk'; import boxen from 'boxen'; import fs from 'fs'; import https from 'https'; +import http from 'http'; import inquirer from 'inquirer'; import ora from 'ora'; // Import ora @@ -48,7 +49,8 @@ import { writeConfig, ConfigurationError, isConfigFilePresent, - getAvailableModels + getAvailableModels, + getBaseUrlForRole } from './config-manager.js'; import { @@ -153,6 +155,64 @@ async function runInteractiveSetup(projectRoot) { }); } + // Helper function to fetch Ollama models (duplicated for CLI context) + function fetchOllamaModelsCLI(baseUrl = 'http://localhost:11434/api') { + return new Promise((resolve) => { + try { + // Parse the base URL to extract hostname, port, and base path + const url = new URL(baseUrl); + const isHttps = url.protocol === 'https:'; + const port = url.port || (isHttps ? 443 : 80); + const basePath = url.pathname.endsWith('/') + ? url.pathname.slice(0, -1) + : url.pathname; + + const options = { + hostname: url.hostname, + port: parseInt(port, 10), + path: `${basePath}/tags`, + method: 'GET', + headers: { + Accept: 'application/json' + } + }; + + const requestLib = isHttps ? https : http; + const req = requestLib.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 200) { + try { + const parsedData = JSON.parse(data); + resolve(parsedData.models || []); // Return the array of models + } catch (e) { + console.error('Error parsing Ollama response:', e); + resolve(null); // Indicate failure + } + } else { + console.error( + `Ollama API request failed with status code: ${res.statusCode}` + ); + resolve(null); // Indicate failure + } + }); + }); + + req.on('error', (e) => { + console.error('Error fetching Ollama models:', e); + resolve(null); // Indicate failure + }); + req.end(); + } catch (e) { + console.error('Error parsing Ollama base URL:', e); + resolve(null); // Indicate failure + } + }); + } + // Helper to get choices and default index for a role const getPromptData = (role, allowNone = false) => { const currentModel = currentModels[role]; // Use the fetched data @@ -180,6 +240,11 @@ async function runInteractiveSetup(projectRoot) { value: '__CUSTOM_OPENROUTER__' }; + const customOllamaOption = { + name: '* Custom Ollama model', // Symbol updated + value: '__CUSTOM_OLLAMA__' + }; + let choices = []; let defaultIndex = 0; // Default to 'Cancel' @@ -225,6 +290,7 @@ async function runInteractiveSetup(projectRoot) { } commonPrefix.push(cancelOption); commonPrefix.push(customOpenRouterOption); + commonPrefix.push(customOllamaOption); let prefixLength = commonPrefix.length; // Initial prefix length @@ -355,6 +421,47 @@ async function runInteractiveSetup(projectRoot) { setupSuccess = false; return true; // Continue setup, but mark as failed } + } else if (selectedValue === '__CUSTOM_OLLAMA__') { + isCustomSelection = true; + const { customId } = await inquirer.prompt([ + { + type: 'input', + name: 'customId', + message: `Enter the custom Ollama Model ID for the ${role} role:` + } + ]); + if (!customId) { + console.log(chalk.yellow('No custom ID entered. Skipping role.')); + return true; // Continue setup, but don't set this role + } + modelIdToSet = customId; + providerHint = 'ollama'; + // Get the Ollama base URL from config for this role + const ollamaBaseUrl = getBaseUrlForRole(role, projectRoot); + // Validate against live Ollama list + const ollamaModels = await fetchOllamaModelsCLI(ollamaBaseUrl); + if (ollamaModels === null) { + console.error( + chalk.red( + `Error: Unable to connect to Ollama server at ${ollamaBaseUrl}. Please ensure Ollama is running and try again.` + ) + ); + setupSuccess = false; + return true; // Continue setup, but mark as failed + } else if (!ollamaModels.some((m) => m.model === modelIdToSet)) { + console.error( + chalk.red( + `Error: Model ID "${modelIdToSet}" not found in the Ollama instance. Please verify the model is pulled and available.` + ) + ); + console.log( + chalk.yellow( + `You can check available models with: curl ${ollamaBaseUrl}/tags` + ) + ); + setupSuccess = false; + return true; // Continue setup, but mark as failed + } } else if ( selectedValue && typeof selectedValue === 'object' && diff --git a/scripts/modules/task-manager/models.js b/scripts/modules/task-manager/models.js index 1ee63175..77a6ae4f 100644 --- a/scripts/modules/task-manager/models.js +++ b/scripts/modules/task-manager/models.js @@ -6,6 +6,7 @@ import path from 'path'; import fs from 'fs'; import https from 'https'; +import http from 'http'; import { getMainModelId, getResearchModelId, @@ -19,7 +20,8 @@ import { getConfig, writeConfig, isConfigFilePresent, - getAllProviders + getAllProviders, + getBaseUrlForRole } from '../config-manager.js'; /** @@ -68,6 +70,68 @@ function fetchOpenRouterModels() { }); } +/** + * Fetches the list of models from Ollama instance. + * @param {string} baseUrl - The base URL for the Ollama API (e.g., "http://localhost:11434/api") + * @returns {Promise} A promise that resolves with the list of model objects or null if fetch fails. + */ +function fetchOllamaModels(baseUrl = 'http://localhost:11434/api') { + return new Promise((resolve) => { + try { + // Parse the base URL to extract hostname, port, and base path + const url = new URL(baseUrl); + const isHttps = url.protocol === 'https:'; + const port = url.port || (isHttps ? 443 : 80); + const basePath = url.pathname.endsWith('/') + ? url.pathname.slice(0, -1) + : url.pathname; + + const options = { + hostname: url.hostname, + port: parseInt(port, 10), + path: `${basePath}/tags`, + method: 'GET', + headers: { + Accept: 'application/json' + } + }; + + const requestLib = isHttps ? https : http; + const req = requestLib.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 200) { + try { + const parsedData = JSON.parse(data); + resolve(parsedData.models || []); // Return the array of models + } catch (e) { + console.error('Error parsing Ollama response:', e); + resolve(null); // Indicate failure + } + } else { + console.error( + `Ollama API request failed with status code: ${res.statusCode}` + ); + resolve(null); // Indicate failure + } + }); + }); + + req.on('error', (e) => { + console.error('Error fetching Ollama models:', e); + resolve(null); // Indicate failure + }); + req.end(); + } catch (e) { + console.error('Error parsing Ollama base URL:', e); + resolve(null); // Indicate failure + } + }); +} + /** * Get the current model configuration * @param {Object} [options] - Options for the operation @@ -416,10 +480,29 @@ async function setModel(role, modelId, options = {}) { ); } } else if (providerHint === 'ollama') { - // Hinted as Ollama - set provider directly WITHOUT checking OpenRouter - determinedProvider = 'ollama'; - warningMessage = `Warning: Custom Ollama model '${modelId}' set. Ensure your Ollama server is running and has pulled this model. Taskmaster cannot guarantee compatibility.`; - report('warn', warningMessage); + // Check Ollama ONLY because hint was ollama + report('info', `Checking Ollama for ${modelId} (as hinted)...`); + + // Get the Ollama base URL from config + const ollamaBaseUrl = getBaseUrlForRole(role, projectRoot); + const ollamaModels = await fetchOllamaModels(ollamaBaseUrl); + + if (ollamaModels === null) { + // Connection failed - server probably not running + throw new Error( + `Unable to connect to Ollama server at ${ollamaBaseUrl}. Please ensure Ollama is running and try again.` + ); + } else if (ollamaModels.some((m) => m.model === modelId)) { + determinedProvider = 'ollama'; + warningMessage = `Warning: Custom Ollama model '${modelId}' set. Ensure your Ollama server is running and has pulled this model. Taskmaster cannot guarantee compatibility.`; + report('warn', warningMessage); + } else { + // Server is running but model not found + const tagsUrl = `${ollamaBaseUrl}/tags`; + throw new Error( + `Model ID "${modelId}" not found in the Ollama instance. Please verify the model is pulled and available. You can check available models with: curl ${tagsUrl}` + ); + } } else { // Invalid provider hint - should not happen throw new Error(`Invalid provider hint received: ${providerHint}`);