feat(config): Add Fallback Model and Expanded Provider Support

Introduces a configurable fallback model and adds support for additional AI provider API keys in the environment setup.

- **Add Fallback Model Configuration (.taskmasterconfig):**
  - Implemented a new  section in .
  - Configured  as the default fallback model, enhancing resilience if the primary model fails.

- **Update Default Model Configuration (.taskmasterconfig):**
  - Changed the default  model to .
  - Changed the default  model to .

- **Add API Key Examples (assets/env.example):**
  - Added example environment variables for:
    -  (for OpenAI/OpenRouter)
    -  (for Google Gemini)
    -  (for XAI Grok)
  - Included format comments for clarity.
This commit is contained in:
Eyal Toledano
2025-04-16 00:35:30 -04:00
parent d84c2486e4
commit 1ab836f191
12 changed files with 1638 additions and 68 deletions

View File

@@ -11,6 +11,7 @@ import fs from 'fs';
import https from 'https';
import inquirer from 'inquirer';
import ora from 'ora';
import Table from 'cli-table3';
import { CONFIG, log, readJSON, writeJSON } from './utils.js';
import {
@@ -40,6 +41,22 @@ import {
fixDependenciesCommand
} from './dependency-manager.js';
import {
getMainModelId,
getResearchModelId,
getFallbackModelId,
setMainModel,
setResearchModel,
setFallbackModel,
getAvailableModels,
VALID_PROVIDERS,
getMainProvider,
getResearchProvider,
getFallbackProvider,
hasApiKeyForProvider,
getMcpApiKeyStatus
} from './config-manager.js';
import {
displayBanner,
displayHelp,
@@ -1548,7 +1565,527 @@ function registerCommands(programInstance) {
}
});
// Add more commands as needed...
// models command
programInstance
.command('models')
.description('Manage AI model configurations')
.option(
'--set-main <model_id>',
'Set the primary model for task generation/updates'
)
.option(
'--set-research <model_id>',
'Set the model for research-backed operations'
)
.option(
'--set-fallback <model_id>',
'Set the model to use if the primary fails'
)
.option('--setup', 'Run interactive setup to configure models')
.action(async (options) => {
let modelSetAction = false; // Track if any set action was performed
const availableModels = getAvailableModels(); // Get available models once
// Helper to find provider for a given model ID
const findProvider = (modelId) => {
const modelInfo = availableModels.find((m) => m.id === modelId);
return modelInfo?.provider;
};
try {
if (options.setMain) {
const modelId = options.setMain;
if (typeof modelId !== 'string' || modelId.trim() === '') {
console.error(
chalk.red('Error: --set-main flag requires a valid model ID.')
);
process.exit(1);
}
const provider = findProvider(modelId);
if (!provider) {
console.error(
chalk.red(
`Error: Model ID "${modelId}" not found in available models.`
)
);
process.exit(1);
}
if (setMainModel(provider, modelId)) {
// Call specific setter
console.log(
chalk.green(
`Main model set to: ${modelId} (Provider: ${provider})`
)
);
modelSetAction = true;
} else {
console.error(chalk.red(`Failed to set main model.`));
process.exit(1);
}
}
if (options.setResearch) {
const modelId = options.setResearch;
if (typeof modelId !== 'string' || modelId.trim() === '') {
console.error(
chalk.red('Error: --set-research flag requires a valid model ID.')
);
process.exit(1);
}
const provider = findProvider(modelId);
if (!provider) {
console.error(
chalk.red(
`Error: Model ID "${modelId}" not found in available models.`
)
);
process.exit(1);
}
if (setResearchModel(provider, modelId)) {
// Call specific setter
console.log(
chalk.green(
`Research model set to: ${modelId} (Provider: ${provider})`
)
);
modelSetAction = true;
} else {
console.error(chalk.red(`Failed to set research model.`));
process.exit(1);
}
}
if (options.setFallback) {
const modelId = options.setFallback;
if (typeof modelId !== 'string' || modelId.trim() === '') {
console.error(
chalk.red('Error: --set-fallback flag requires a valid model ID.')
);
process.exit(1);
}
const provider = findProvider(modelId);
if (!provider) {
console.error(
chalk.red(
`Error: Model ID "${modelId}" not found in available models.`
)
);
process.exit(1);
}
if (setFallbackModel(provider, modelId)) {
// Call specific setter
console.log(
chalk.green(
`Fallback model set to: ${modelId} (Provider: ${provider})`
)
);
modelSetAction = true;
} else {
console.error(chalk.red(`Failed to set fallback model.`));
process.exit(1);
}
}
// Handle interactive setup first
if (options.setup) {
console.log(chalk.cyan.bold('\nInteractive Model Setup:'));
// Filter out placeholder models for selection
const selectableModels = availableModels
.filter(
(model) => !(model.id.startsWith('[') && model.id.endsWith(']'))
)
.map((model) => ({
name: `${model.provider} / ${model.id}`,
value: { provider: model.provider, id: model.id }
}));
if (selectableModels.length === 0) {
console.error(
chalk.red('Error: No selectable models found in configuration.')
);
process.exit(1);
}
const answers = await inquirer.prompt([
{
type: 'list',
name: 'mainModel',
message: 'Select the main model for generation/updates:',
choices: selectableModels,
default: selectableModels.findIndex(
(m) => m.value.id === getMainModelId()
)
},
{
type: 'list',
name: 'researchModel',
message: 'Select the research model:',
// Filter choices to only include models allowed for research
choices: selectableModels.filter((modelChoice) => {
// Need to find the original model data to check allowed_roles
const originalModel = availableModels.find(
(m) => m.id === modelChoice.value.id
);
return originalModel?.allowed_roles?.includes('research');
}),
default: selectableModels.findIndex(
(m) => m.value.id === getResearchModelId()
)
},
{
type: 'list',
name: 'fallbackModel',
message: 'Select the fallback model (optional):',
choices: [
{ name: 'None (disable fallback)', value: null },
new inquirer.Separator(),
...selectableModels
],
default:
selectableModels.findIndex(
(m) => m.value.id === getFallbackModelId()
) + 2 // Adjust for separator and None
}
]);
let setupSuccess = true;
// Set Main Model
if (answers.mainModel) {
if (
!setMainModel(answers.mainModel.provider, answers.mainModel.id)
) {
console.error(chalk.red('Failed to set main model.'));
setupSuccess = false;
} else {
// Success message printed by setMainModel
}
}
// Set Research Model
if (answers.researchModel) {
if (
!setResearchModel(
answers.researchModel.provider,
answers.researchModel.id
)
) {
console.error(chalk.red('Failed to set research model.'));
setupSuccess = false;
} else {
// Success message printed by setResearchModel
}
}
// Set Fallback Model
if (answers.fallbackModel) {
if (
!setFallbackModel(
answers.fallbackModel.provider,
answers.fallbackModel.id
)
) {
console.error(chalk.red('Failed to set fallback model.'));
setupSuccess = false;
} else {
console.log(
chalk.green(
`Fallback model set to: ${answers.fallbackModel.provider} / ${answers.fallbackModel.id}`
)
);
}
} else {
// User selected None - attempt to remove fallback from config
const config = readConfig();
if (config.models.fallback) {
delete config.models.fallback;
if (!writeConfig(config)) {
console.error(
chalk.red('Failed to remove fallback model configuration.')
);
setupSuccess = false;
} else {
console.log(chalk.green('Fallback model disabled.'));
}
}
}
if (setupSuccess) {
console.log(chalk.green.bold('\nModel setup complete!'));
}
return; // Exit after setup
}
// If no set flags were used and not in setup mode, list the models
if (!modelSetAction && !options.setup) {
// Fetch current settings
const mainProvider = getMainProvider();
const mainModelId = getMainModelId();
const researchProvider = getResearchProvider();
const researchModelId = getResearchModelId();
const fallbackProvider = getFallbackProvider(); // May be undefined
const fallbackModelId = getFallbackModelId(); // May be undefined
// Check API keys for both CLI (.env) and MCP (mcp.json)
const mainCliKeyOk = hasApiKeyForProvider(mainProvider);
const mainMcpKeyOk = getMcpApiKeyStatus(mainProvider);
const researchCliKeyOk = hasApiKeyForProvider(researchProvider);
const researchMcpKeyOk = getMcpApiKeyStatus(researchProvider);
const fallbackCliKeyOk = fallbackProvider
? hasApiKeyForProvider(fallbackProvider)
: true; // No key needed if no fallback is set
const fallbackMcpKeyOk = fallbackProvider
? getMcpApiKeyStatus(fallbackProvider)
: true; // No key needed if no fallback is set
// --- Generate Warning Messages ---
const warnings = [];
if (!mainCliKeyOk || !mainMcpKeyOk) {
warnings.push(
`Main model (${mainProvider}): API key missing for ${!mainCliKeyOk ? 'CLI (.env)' : ''}${!mainCliKeyOk && !mainMcpKeyOk ? ' / ' : ''}${!mainMcpKeyOk ? 'MCP (.cursor/mcp.json)' : ''}`
);
}
if (!researchCliKeyOk || !researchMcpKeyOk) {
warnings.push(
`Research model (${researchProvider}): API key missing for ${!researchCliKeyOk ? 'CLI (.env)' : ''}${!researchCliKeyOk && !researchMcpKeyOk ? ' / ' : ''}${!researchMcpKeyOk ? 'MCP (.cursor/mcp.json)' : ''}`
);
}
if (fallbackProvider && (!fallbackCliKeyOk || !fallbackMcpKeyOk)) {
warnings.push(
`Fallback model (${fallbackProvider}): API key missing for ${!fallbackCliKeyOk ? 'CLI (.env)' : ''}${!fallbackCliKeyOk && !fallbackMcpKeyOk ? ' / ' : ''}${!fallbackMcpKeyOk ? 'MCP (.cursor/mcp.json)' : ''}`
);
}
// --- Display Warning Banner (if any) ---
if (warnings.length > 0) {
console.log(
boxen(
chalk.red.bold('API Key Warnings:') +
'\n\n' +
warnings.join('\n'),
{
padding: 1,
margin: { top: 1, bottom: 1 },
borderColor: 'red',
borderStyle: 'round'
}
)
);
}
// --- Active Configuration Section ---
console.log(chalk.cyan.bold('\nActive Model Configuration:'));
const activeTable = new Table({
head: [
'Role',
'Provider',
'Model ID',
'SWE Score', // Update column name
'Cost ($/1M tkns)', // Add Cost column
'API Key Status'
].map((h) => chalk.cyan.bold(h)),
colWidths: [10, 14, 30, 18, 20, 28], // Adjust widths for stars
style: { head: ['cyan', 'bold'] }
});
const allAvailableModels = getAvailableModels(); // Get all models once for lookup
// --- Calculate Tertile Thresholds for SWE Scores ---
const validScores = allAvailableModels
.map((m) => m.swe_score)
.filter((s) => s !== null && s !== undefined && s > 0);
const sortedScores = [...validScores].sort((a, b) => b - a); // Sort descending
const n = sortedScores.length;
let minScore3Stars = -Infinity;
let minScore2Stars = -Infinity;
if (n > 0) {
const topThirdIndex = Math.max(0, Math.floor(n / 3) - 1);
const midThirdIndex = Math.max(0, Math.floor((2 * n) / 3) - 1);
minScore3Stars = sortedScores[topThirdIndex];
minScore2Stars = sortedScores[midThirdIndex];
}
// Helper to find the full model object
const findModelData = (modelId) => {
return allAvailableModels.find((m) => m.id === modelId);
};
// --- Helper to format SWE score and add tertile stars ---
const formatSweScoreWithTertileStars = (score) => {
if (score === null || score === undefined || score <= 0)
return 'N/A'; // Handle non-positive scores
const formattedPercentage = `${(score * 100).toFixed(1)}%`;
let stars = '';
if (n === 0) {
// No valid scores to compare against
stars = chalk.gray('☆☆☆');
} else if (score >= minScore3Stars) {
stars = chalk.yellow('★★★'); // Top Third
} else if (score >= minScore2Stars) {
stars = chalk.yellow('★★') + chalk.gray('☆'); // Middle Third
} else {
stars = chalk.yellow('★') + chalk.gray('☆☆'); // Bottom Third (but > 0)
}
return `${formattedPercentage} ${stars}`;
};
// Helper to format cost
const formatCost = (costObj) => {
if (!costObj) return 'N/A';
const formatSingleCost = (costValue) => {
if (costValue === null || costValue === undefined) return 'N/A';
// Check if the number is an integer
const isInteger = Number.isInteger(costValue);
return `$${costValue.toFixed(isInteger ? 0 : 2)}`;
};
const inputCost = formatSingleCost(costObj.input);
const outputCost = formatSingleCost(costObj.output);
return `${inputCost} in, ${outputCost} out`; // Use cleaner separator
};
const getCombinedStatus = (cliOk, mcpOk) => {
const cliSymbol = cliOk ? chalk.green('✓') : chalk.red('✗');
const mcpSymbol = mcpOk ? chalk.green('✓') : chalk.red('✗');
if (cliOk && mcpOk) {
// Both symbols green, default text color
return `${cliSymbol} CLI & ${mcpSymbol} MCP OK`;
} else if (cliOk && !mcpOk) {
// Symbols colored individually, default text color
return `${cliSymbol} CLI OK / ${mcpSymbol} MCP Missing`;
} else if (!cliOk && mcpOk) {
// Symbols colored individually, default text color
return `${cliSymbol} CLI Missing / ${mcpSymbol} MCP OK`;
} else {
// Both symbols gray, apply overall gray to text as well
return chalk.gray(`${cliSymbol} CLI & MCP Both Missing`);
}
};
const mainModelData = findModelData(mainModelId);
const researchModelData = findModelData(researchModelId);
const fallbackModelData = findModelData(fallbackModelId);
activeTable.push([
chalk.white('Main'),
mainProvider,
mainModelId,
formatSweScoreWithTertileStars(mainModelData?.swe_score), // Use tertile formatter
formatCost(mainModelData?.cost_per_1m_tokens),
getCombinedStatus(mainCliKeyOk, mainMcpKeyOk)
]);
activeTable.push([
chalk.white('Research'),
researchProvider,
researchModelId,
formatSweScoreWithTertileStars(researchModelData?.swe_score), // Use tertile formatter
formatCost(researchModelData?.cost_per_1m_tokens),
getCombinedStatus(researchCliKeyOk, researchMcpKeyOk)
]);
if (fallbackProvider && fallbackModelId) {
activeTable.push([
chalk.white('Fallback'),
fallbackProvider,
fallbackModelId,
formatSweScoreWithTertileStars(fallbackModelData?.swe_score), // Use tertile formatter
formatCost(fallbackModelData?.cost_per_1m_tokens),
getCombinedStatus(fallbackCliKeyOk, fallbackMcpKeyOk)
]);
}
console.log(activeTable.toString());
// --- Available Models Section ---
// const availableModels = getAvailableModels(); // Already fetched
if (!allAvailableModels || allAvailableModels.length === 0) {
console.log(chalk.yellow('\nNo available models defined.'));
return;
}
// Filter out placeholders and active models for the available list
const activeIds = [
mainModelId,
researchModelId,
fallbackModelId
].filter(Boolean);
const filteredAvailable = allAvailableModels.filter(
(model) =>
!(model.id.startsWith('[') && model.id.endsWith(']')) &&
!activeIds.includes(model.id)
);
if (filteredAvailable.length > 0) {
console.log(chalk.cyan.bold('\nOther Available Models:'));
const availableTable = new Table({
head: [
'Provider',
'Model ID',
'SWE Score', // Update column name
'Cost ($/1M tkns)' // Add Cost column
].map((h) => chalk.cyan.bold(h)),
colWidths: [15, 40, 18, 25], // Adjust widths for stars
style: { head: ['cyan', 'bold'] }
});
filteredAvailable.forEach((model) => {
availableTable.push([
model.provider || 'N/A',
model.id,
formatSweScoreWithTertileStars(model.swe_score), // Use tertile formatter
formatCost(model.cost_per_1m_tokens)
]);
});
console.log(availableTable.toString());
} else {
console.log(
chalk.gray('\n(All available models are currently configured)')
);
}
// --- Suggested Actions Section ---
console.log(
boxen(
chalk.white.bold('Next Steps:') +
'\n' +
chalk.cyan(
`1. Set main model: ${chalk.yellow('task-master models --set-main <model_id>')}`
) +
'\n' +
chalk.cyan(
`2. Set research model: ${chalk.yellow('task-master models --set-research <model_id>')}`
) +
'\n' +
chalk.cyan(
`3. Set fallback model: ${chalk.yellow('task-master models --set-fallback <model_id>')}`
) +
'\n' +
chalk.cyan(
`4. Run interactive setup: ${chalk.yellow('task-master models --setup')}`
),
{
padding: 1,
borderColor: 'yellow',
borderStyle: 'round',
margin: { top: 1 }
}
)
);
}
} catch (error) {
log(`Error processing models command: ${error.message}`, 'error');
if (error.stack && CONFIG.debug) {
log(error.stack, 'debug');
}
process.exit(1);
}
});
return programInstance;
}

View File

@@ -1,6 +1,30 @@
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
// Calculate __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load supported models from JSON file using the calculated __dirname
let MODEL_MAP;
try {
const supportedModelsRaw = fs.readFileSync(
path.join(__dirname, 'supported-models.json'),
'utf-8'
);
MODEL_MAP = JSON.parse(supportedModelsRaw);
} catch (error) {
console.error(
chalk.red(
'FATAL ERROR: Could not load supported-models.json. Please ensure the file exists and is valid JSON.'
),
error
);
MODEL_MAP = {}; // Default to empty map on error to avoid crashing, though functionality will be limited
process.exit(1); // Exit if models can't be loaded
}
const CONFIG_FILE_NAME = '.taskmasterconfig';
@@ -21,17 +45,6 @@ const VALID_PROVIDERS = [
'grok'
];
// Optional: Define known models per provider primarily for informational display or non-blocking warnings
const MODEL_MAP = {
anthropic: ['claude-3.5-sonnet-20240620', 'claude-3-7-sonnet-20250219'],
openai: ['gpt-4o', 'gpt-4-turbo'],
google: ['gemini-2.5-pro-latest', 'gemini-1.5-flash-latest'],
perplexity: ['sonar-pro', 'sonar-mini'],
ollama: [], // Users configure specific Ollama models locally
openrouter: [], // Users specify model string
grok: [] // Specify Grok model if known
};
let projectRoot = null;
function findProjectRoot() {
@@ -106,11 +119,16 @@ function readConfig(explicitRoot = null) {
modelId:
parsedConfig?.models?.research?.modelId ??
defaults.models.research.modelId
},
// Add merge logic for the fallback model
fallback: {
provider: parsedConfig?.models?.fallback?.provider,
modelId: parsedConfig?.models?.fallback?.modelId
}
}
};
// Validate loaded provider (no longer split by main/research)
// Validate loaded providers (main, research, and fallback if it exists)
if (!validateProvider(config.models.main.provider)) {
console.warn(
chalk.yellow(
@@ -139,6 +157,21 @@ function readConfig(explicitRoot = null) {
// Optional: Add warning for model combination if desired, but don't block
// else if (!validateProviderModelCombination(config.models.research.provider, config.models.research.modelId)) { ... }
// Add validation for fallback provider if it exists
if (
config.models.fallback &&
config.models.fallback.provider &&
!validateProvider(config.models.fallback.provider)
) {
console.warn(
chalk.yellow(
`Warning: Invalid fallback provider "${config.models.fallback.provider}" in ${CONFIG_FILE_NAME}. Fallback model will be ignored.`
)
);
// Unlike main/research, we don't set a default fallback, just ignore it
delete config.models.fallback;
}
return config;
} catch (error) {
console.error(
@@ -177,7 +210,8 @@ function validateProviderModelCombination(providerName, modelId) {
// If the provider is known, check if the model is in its list OR if the list is empty (meaning accept any)
return (
MODEL_MAP[providerName].length === 0 ||
MODEL_MAP[providerName].includes(modelId)
// Use .some() to check the 'id' property of objects in the array
MODEL_MAP[providerName].some((modelObj) => modelObj.id === modelId)
);
}
@@ -221,6 +255,26 @@ function getResearchModelId(explicitRoot = null) {
return config.models.research.modelId;
}
/**
* Gets the currently configured fallback AI provider.
* @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {string|undefined} The name of the fallback provider, or undefined if not set.
*/
function getFallbackProvider(explicitRoot = null) {
const config = readConfig(explicitRoot);
return config.models?.fallback?.provider;
}
/**
* Gets the currently configured fallback AI model ID.
* @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {string|undefined} The ID of the fallback model, or undefined if not set.
*/
function getFallbackModelId(explicitRoot = null) {
const config = readConfig(explicitRoot);
return config.models?.fallback?.modelId;
}
/**
* Sets the main AI model (provider and modelId) in the configuration file.
* @param {string} providerName The name of the provider to set.
@@ -229,6 +283,7 @@ function getResearchModelId(explicitRoot = null) {
* @returns {boolean} True if successful, false otherwise.
*/
function setMainModel(providerName, modelId, explicitRoot = null) {
// --- 1. Validate Provider First ---
if (!validateProvider(providerName)) {
console.error(
chalk.red(`Error: "${providerName}" is not a valid provider.`)
@@ -238,6 +293,35 @@ function setMainModel(providerName, modelId, explicitRoot = null) {
);
return false;
}
// --- 2. Validate Role Second ---
const allModels = getAvailableModels(); // Get all models to check roles
const modelData = allModels.find(
(m) => m.id === modelId && m.provider === providerName
);
if (
!modelData ||
!modelData.allowed_roles ||
!modelData.allowed_roles.includes('main')
) {
console.error(
chalk.red(`Error: Model "${modelId}" is not allowed for the 'main' role.`)
);
// Try to suggest valid models for the role
const allowedMainModels = allModels
.filter((m) => m.allowed_roles?.includes('main'))
.map((m) => ` - ${m.provider} / ${m.id}`)
.join('\n');
if (allowedMainModels) {
console.log(
chalk.yellow('\nAllowed models for main role:\n' + allowedMainModels)
);
}
return false;
}
// --- 3. Validate Model Combination (Optional Warning) ---
if (!validateProviderModelCombination(providerName, modelId)) {
console.warn(
chalk.yellow(
@@ -246,7 +330,7 @@ function setMainModel(providerName, modelId, explicitRoot = null) {
);
}
// Pass explicitRoot down
// --- Proceed with setting ---
const config = readConfig(explicitRoot);
config.models.main = { provider: providerName, modelId: modelId };
// Pass explicitRoot down
@@ -268,6 +352,7 @@ function setMainModel(providerName, modelId, explicitRoot = null) {
* @returns {boolean} True if successful, false otherwise.
*/
function setResearchModel(providerName, modelId, explicitRoot = null) {
// --- 1. Validate Provider First ---
if (!validateProvider(providerName)) {
console.error(
chalk.red(`Error: "${providerName}" is not a valid provider.`)
@@ -277,6 +362,39 @@ function setResearchModel(providerName, modelId, explicitRoot = null) {
);
return false;
}
// --- 2. Validate Role Second ---
const allModels = getAvailableModels(); // Get all models to check roles
const modelData = allModels.find(
(m) => m.id === modelId && m.provider === providerName
);
if (
!modelData ||
!modelData.allowed_roles ||
!modelData.allowed_roles.includes('research')
) {
console.error(
chalk.red(
`Error: Model "${modelId}" is not allowed for the 'research' role.`
)
);
// Try to suggest valid models for the role
const allowedResearchModels = allModels
.filter((m) => m.allowed_roles?.includes('research'))
.map((m) => ` - ${m.provider} / ${m.id}`)
.join('\n');
if (allowedResearchModels) {
console.log(
chalk.yellow(
'\nAllowed models for research role:\n' + allowedResearchModels
)
);
}
return false;
}
// --- 3. Validate Model Combination (Optional Warning) ---
if (!validateProviderModelCombination(providerName, modelId)) {
console.warn(
chalk.yellow(
@@ -284,6 +402,8 @@ function setResearchModel(providerName, modelId, explicitRoot = null) {
)
);
}
// --- 4. Specific Research Warning (Optional) ---
if (
providerName === 'anthropic' ||
(providerName === 'openai' && modelId.includes('3.5'))
@@ -295,7 +415,7 @@ function setResearchModel(providerName, modelId, explicitRoot = null) {
);
}
// Pass explicitRoot down
// --- Proceed with setting ---
const config = readConfig(explicitRoot);
config.models.research = { provider: providerName, modelId: modelId };
// Pass explicitRoot down
@@ -309,37 +429,257 @@ function setResearchModel(providerName, modelId, explicitRoot = null) {
}
}
/**
* Sets the fallback AI model (provider and modelId) in the configuration file.
* @param {string} providerName The name of the provider to set.
* @param {string} modelId The ID of the model to set.
* @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {boolean} True if successful, false otherwise.
*/
function setFallbackModel(providerName, modelId, explicitRoot = null) {
// --- 1. Validate Provider First ---
if (!validateProvider(providerName)) {
console.error(
chalk.red(`Error: "${providerName}" is not a valid provider.`)
);
console.log(
chalk.yellow(`Available providers: ${VALID_PROVIDERS.join(', ')}`)
);
return false;
}
// --- 2. Validate Role Second ---
const allModels = getAvailableModels(); // Get all models to check roles
const modelData = allModels.find(
(m) => m.id === modelId && m.provider === providerName
);
if (
!modelData ||
!modelData.allowed_roles ||
!modelData.allowed_roles.includes('fallback')
) {
console.error(
chalk.red(
`Error: Model "${modelId}" is not allowed for the 'fallback' role.`
)
);
// Try to suggest valid models for the role
const allowedFallbackModels = allModels
.filter((m) => m.allowed_roles?.includes('fallback'))
.map((m) => ` - ${m.provider} / ${m.id}`)
.join('\n');
if (allowedFallbackModels) {
console.log(
chalk.yellow(
'\nAllowed models for fallback role:\n' + allowedFallbackModels
)
);
}
return false;
}
// --- 3. Validate Model Combination (Optional Warning) ---
if (!validateProviderModelCombination(providerName, modelId)) {
console.warn(
chalk.yellow(
`Warning: Model "${modelId}" is not in the known list for provider "${providerName}". Ensure it is valid.`
)
);
}
// --- Proceed with setting ---
const config = readConfig(explicitRoot);
if (!config.models) {
config.models = {}; // Ensure models object exists
}
// Ensure fallback object exists
if (!config.models.fallback) {
config.models.fallback = {};
}
config.models.fallback = { provider: providerName, modelId: modelId };
return writeConfig(config, explicitRoot);
}
/**
* Gets a list of available models based on the MODEL_MAP.
* @returns {Array<{id: string, name: string, provider: string, swe_score: number|null, cost_per_1m_tokens: {input: number|null, output: number|null}|null, allowed_roles: string[]}>}
*/
function getAvailableModels() {
const available = [];
for (const [provider, models] of Object.entries(MODEL_MAP)) {
if (models.length > 0) {
models.forEach((modelObj) => {
// Basic name generation - can be improved
const modelId = modelObj.id;
const sweScore = modelObj.swe_score;
const cost = modelObj.cost_per_1m_tokens;
const allowedRoles = modelObj.allowed_roles || ['main', 'fallback'];
const nameParts = modelId
.split('-')
.map((p) => p.charAt(0).toUpperCase() + p.slice(1));
// Handle specific known names better if needed
let name = nameParts.join(' ');
if (modelId === 'claude-3.5-sonnet-20240620')
name = 'Claude 3.5 Sonnet';
if (modelId === 'claude-3-7-sonnet-20250219')
name = 'Claude 3.7 Sonnet';
if (modelId === 'gpt-4o') name = 'GPT-4o';
if (modelId === 'gpt-4-turbo') name = 'GPT-4 Turbo';
if (modelId === 'sonar-pro') name = 'Perplexity Sonar Pro';
if (modelId === 'sonar-mini') name = 'Perplexity Sonar Mini';
available.push({
id: modelId,
name: name,
provider: provider,
swe_score: sweScore,
cost_per_1m_tokens: cost,
allowed_roles: allowedRoles
});
});
} else {
// For providers with empty lists (like ollama), maybe add a placeholder or skip
available.push({
id: `[${provider}-any]`,
name: `Any (${provider})`,
provider: provider
});
}
}
return available;
}
/**
* Writes the configuration object to the file.
* @param {Object} config The configuration object to write.
* @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {boolean} True if successful, false otherwise.
*/
function writeConfig(config, explicitRoot = null) {
// Determine the root path to use
const rootToUse = explicitRoot || findProjectRoot();
if (!rootToUse) {
const rootPath = explicitRoot || findProjectRoot();
if (!rootPath) {
console.error(
chalk.red(
'Error: Could not determine project root to write configuration.'
)
);
return false;
}
const configPath = path.join(rootToUse, CONFIG_FILE_NAME);
// Check if file exists, as expected by tests
if (!fs.existsSync(configPath)) {
console.error(
chalk.red(
`Error: ${CONFIG_FILE_NAME} does not exist. Create it first or initialize project.`
'Error: Could not determine project root. Configuration not saved.'
)
);
return false;
}
// Ensure we don't double-join if explicitRoot already contains the filename
const configPath =
path.basename(rootPath) === CONFIG_FILE_NAME
? rootPath
: path.join(rootPath, CONFIG_FILE_NAME);
try {
// Added 'utf-8' encoding
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return true;
} catch (error) {
console.error(
chalk.red(`Error writing to ${configPath}: ${error.message}.`)
chalk.red(
`Error writing configuration to ${configPath}: ${error.message}`
)
);
return false;
}
}
/**
* Checks if the required API key environment variable is set for a given provider.
* @param {string} providerName The name of the provider.
* @returns {boolean} True if the API key environment variable exists and is non-empty, false otherwise.
*/
function hasApiKeyForProvider(providerName) {
switch (providerName) {
case 'anthropic':
return !!process.env.ANTHROPIC_API_KEY;
case 'openai':
case 'openrouter': // OpenRouter uses OpenAI-compatible key
return !!process.env.OPENAI_API_KEY;
case 'google':
return !!process.env.GOOGLE_API_KEY;
case 'perplexity':
return !!process.env.PERPLEXITY_API_KEY;
case 'grok':
case 'xai': // Added alias for Grok
return !!process.env.GROK_API_KEY;
case 'ollama':
return true; // Ollama runs locally, no cloud API key needed
default:
return false; // Unknown provider cannot have a key checked
}
}
/**
* Checks the API key status within .cursor/mcp.json for a given provider.
* Reads the mcp.json file, finds the taskmaster-ai server config, and checks the relevant env var.
* @param {string} providerName The name of the provider.
* @returns {boolean} True if the key exists and is not a placeholder, false otherwise.
*/
function getMcpApiKeyStatus(providerName) {
const rootDir = findProjectRoot(); // Use existing root finding
if (!rootDir) {
console.warn(
chalk.yellow('Warning: Could not find project root to check mcp.json.')
);
return false; // Cannot check without root
}
const mcpConfigPath = path.join(rootDir, '.cursor', 'mcp.json');
if (!fs.existsSync(mcpConfigPath)) {
// console.warn(chalk.yellow('Warning: .cursor/mcp.json not found.'));
return false; // File doesn't exist
}
try {
const mcpConfigRaw = fs.readFileSync(mcpConfigPath, 'utf-8');
const mcpConfig = JSON.parse(mcpConfigRaw);
const mcpEnv = mcpConfig?.mcpServers?.['taskmaster-ai']?.env;
if (!mcpEnv) {
// console.warn(chalk.yellow('Warning: Could not find taskmaster-ai env in mcp.json.'));
return false; // Structure missing
}
let apiKeyToCheck = null;
let placeholderValue = null;
switch (providerName) {
case 'anthropic':
apiKeyToCheck = mcpEnv.ANTHROPIC_API_KEY;
placeholderValue = 'YOUR_ANTHROPIC_API_KEY_HERE';
break;
case 'openai':
case 'openrouter':
apiKeyToCheck = mcpEnv.OPENAI_API_KEY;
placeholderValue = 'YOUR_OPENAI_API_KEY_HERE'; // Assuming placeholder matches OPENAI
break;
case 'google':
apiKeyToCheck = mcpEnv.GOOGLE_API_KEY;
placeholderValue = 'YOUR_GOOGLE_API_KEY_HERE';
break;
case 'perplexity':
apiKeyToCheck = mcpEnv.PERPLEXITY_API_KEY;
placeholderValue = 'YOUR_PERPLEXITY_API_KEY_HERE';
break;
case 'grok':
case 'xai':
apiKeyToCheck = mcpEnv.GROK_API_KEY;
placeholderValue = 'YOUR_GROK_API_KEY_HERE';
break;
case 'ollama':
return true; // No key needed
default:
return false; // Unknown provider
}
return !!apiKeyToCheck && apiKeyToCheck !== placeholderValue;
} catch (error) {
console.error(
chalk.red(`Error reading or parsing .cursor/mcp.json: ${error.message}`)
);
return false;
}
@@ -355,8 +695,14 @@ export {
getMainModelId,
getResearchProvider,
getResearchModelId,
getFallbackProvider,
getFallbackModelId,
setMainModel,
setResearchModel,
setFallbackModel,
VALID_PROVIDERS,
MODEL_MAP
MODEL_MAP,
getAvailableModels,
hasApiKeyForProvider,
getMcpApiKeyStatus
};

View File

@@ -0,0 +1,256 @@
{
"anthropic": [
{
"id": "claude-3.5-sonnet-20240620",
"swe_score": 0.49,
"cost_per_1m_tokens": { "input": 3.0, "output": 15.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "claude-3-7-sonnet-20250219",
"swe_score": 0.623,
"cost_per_1m_tokens": { "input": 3.0, "output": 15.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "claude-3.5-haiku-20241022",
"swe_score": 0.406,
"cost_per_1m_tokens": { "input": 0.8, "output": 4.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "claude-3-haiku-20240307",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 0.25, "output": 1.25 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "claude-3-opus-20240229",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
}
],
"openai": [
{
"id": "gpt-4o",
"swe_score": 0.332,
"cost_per_1m_tokens": { "input": 5.0, "output": 15.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-4-turbo",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 10.0, "output": 30.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "o1",
"swe_score": 0.489,
"cost_per_1m_tokens": { "input": 15.0, "output": 60.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "o3-mini",
"swe_score": 0.493,
"cost_per_1m_tokens": { "input": 1.1, "output": 4.4 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "o1-pro",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 150.0, "output": 600.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-4.1",
"swe_score": 0.55,
"cost_per_1m_tokens": { "input": 2.0, "output": 8.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-4.5-preview",
"swe_score": 0.38,
"cost_per_1m_tokens": { "input": 75.0, "output": 150.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-4.1-mini",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 0.4, "output": 1.6 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-4.1-nano",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 0.1, "output": 0.4 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gpt-3.5-turbo",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 0.5, "output": 1.5 },
"allowed_roles": ["main", "fallback"]
}
],
"google": [
{
"id": "gemini-2.5-pro-latest",
"swe_score": 0.638,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "gemini-1.5-flash-latest",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "gemini-2.0-flash-experimental",
"swe_score": 0.754,
"cost_per_1m_tokens": { "input": 0.15, "output": 0.6 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gemini-2.0-flash-thinking-experimental",
"swe_score": 0.754,
"cost_per_1m_tokens": { "input": 0.15, "output": 0.6 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "gemini-2.0-pro",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "gemma-3-7b",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
}
],
"perplexity": [
{
"id": "sonar-pro",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback", "research"]
},
{
"id": "sonar-mini",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback", "research"]
},
{
"id": "deep-research",
"swe_score": 0.211,
"cost_per_1m_tokens": { "input": 2.0, "output": 8.0 },
"allowed_roles": ["main", "fallback", "research"]
}
],
"ollama": [
{
"id": "llava",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "deepseek-coder-v2",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "dolphin3",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "olmo2-7b",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "olmo2-13b",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
}
],
"openrouter": [
{
"id": "meta-llama/llama-4-scout",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "google/gemini-2.5-pro-exp-03-25",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "openrouter/optimus-alpha",
"swe_score": 0,
"cost_per_1m_tokens": { "input": 30.0, "output": 60.0 },
"allowed_roles": ["main", "fallback"]
},
{
"id": "openrouter/quasar-alpha",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "kimi-vl-a3b-thinking",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "qwen2.5-max",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
}
],
"grok": [
{
"id": "grok3-beta",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback", "research"]
},
{
"id": "grok-3-mini",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "grok-2",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "grok-2-mini",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
},
{
"id": "grok-1.5",
"swe_score": 0,
"cost_per_1m_tokens": null,
"allowed_roles": ["main", "fallback"]
}
]
}