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
This commit is contained in:
15
.changeset/better-seas-sit.md
Normal file
15
.changeset/better-seas-sit.md
Normal file
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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<Array|null>} 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}`);
|
||||
|
||||
Reference in New Issue
Block a user