feat(ai): Add Google Gemini provider support and fix config loading

This commit is contained in:
Eyal Toledano
2025-04-27 01:23:18 -04:00
parent 66743c3962
commit cbc3576642
15 changed files with 739 additions and 188 deletions

View File

@@ -24,6 +24,7 @@ import { log, resolveEnvVariable } from './utils.js';
// Corrected path from scripts/ai-providers/... to ../../src/ai-providers/...
import * as anthropic from '../../src/ai-providers/anthropic.js';
import * as perplexity from '../../src/ai-providers/perplexity.js';
import * as google from '../../src/ai-providers/google.js'; // Import Google provider
// TODO: Import other provider modules when implemented (openai, ollama, etc.)
// --- Provider Function Map ---
@@ -40,6 +41,12 @@ const PROVIDER_FUNCTIONS = {
streamText: perplexity.streamPerplexityText,
generateObject: perplexity.generatePerplexityObject
// streamObject: perplexity.streamPerplexityObject, // Add when implemented
},
google: {
// Add Google entry
generateText: google.generateGoogleText,
streamText: google.streamGoogleText,
generateObject: google.generateGoogleObject
}
// TODO: Add entries for openai, ollama, etc. when implemented
};
@@ -75,7 +82,7 @@ function _resolveApiKey(providerName, session) {
const keyMap = {
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_API_KEY',
google: 'GOOGLE_API_KEY', // Add Google API Key
perplexity: 'PERPLEXITY_API_KEY',
mistral: 'MISTRAL_API_KEY',
azure: 'AZURE_OPENAI_API_KEY',

View File

@@ -66,7 +66,7 @@ import {
getAvailableModelsList,
setModel
} from './task-manager/models.js'; // Import new core functions
import { findProjectRoot } from './utils.js';
import { findProjectRoot } from './utils.js'; // Import findProjectRoot
/**
* Configure and register CLI commands
@@ -1597,15 +1597,37 @@ function registerCommands(programInstance) {
.option('--setup', 'Run interactive setup to configure models')
.action(async (options) => {
try {
// ---> Explicitly find project root for CLI execution <---
const projectRoot = findProjectRoot();
if (!projectRoot && !options.setup) {
// Allow setup even if root isn't found immediately
console.error(
chalk.red(
"Error: Could not determine the project root. Ensure you're running this command within a Task Master project directory."
)
);
process.exit(1);
}
// ---> End find project root <---
// --- Set Operations ---
if (options.setMain || options.setResearch || options.setFallback) {
let resultSet = null;
const coreOptions = { projectRoot }; // Pass root to setModel
if (options.setMain) {
resultSet = await setModel('main', options.setMain);
resultSet = await setModel('main', options.setMain, coreOptions);
} else if (options.setResearch) {
resultSet = await setModel('research', options.setResearch);
resultSet = await setModel(
'research',
options.setResearch,
coreOptions
);
} else if (options.setFallback) {
resultSet = await setModel('fallback', options.setFallback);
resultSet = await setModel(
'fallback',
options.setFallback,
coreOptions
);
}
if (resultSet?.success) {
@@ -1619,7 +1641,7 @@ function registerCommands(programInstance) {
if (resultSet?.error?.code === 'MODEL_NOT_FOUND') {
console.log(
chalk.yellow(
'\nRun `task-master models` to see available models.'
'\\nRun `task-master models` to see available models.'
)
);
}
@@ -1630,8 +1652,10 @@ function registerCommands(programInstance) {
// --- Interactive Setup ---
if (options.setup) {
// Get available models for interactive setup
const availableModelsResult = await getAvailableModelsList();
// Get available models for interactive setup - pass projectRoot
const availableModelsResult = await getAvailableModelsList({
projectRoot
});
if (!availableModelsResult.success) {
console.error(
chalk.red(
@@ -1642,7 +1666,10 @@ function registerCommands(programInstance) {
}
const availableModelsForSetup = availableModelsResult.data.models;
const currentConfigResult = await getModelConfiguration();
// Get current config - pass projectRoot
const currentConfigResult = await getModelConfiguration({
projectRoot
});
if (!currentConfigResult.success) {
console.error(
chalk.red(
@@ -1657,24 +1684,12 @@ function registerCommands(programInstance) {
fallback: {}
};
console.log(chalk.cyan.bold('\nInteractive Model Setup:'));
console.log(chalk.cyan.bold('\\nInteractive Model Setup:'));
const getMainChoicesAndDefault = () => {
const mainChoices = allModelsForSetup.filter((modelChoice) =>
availableModelsForSetup
.find((m) => m.modelId === modelChoice.value.id)
?.allowedRoles?.includes('main')
);
const defaultIndex = mainChoices.findIndex(
(m) => m.value.id === currentModels.main?.modelId
);
return { choices: mainChoices, default: defaultIndex };
};
// Get all available models, including active ones
// Find all available models for setup options
const allModelsForSetup = availableModelsForSetup.map((model) => ({
name: `${model.provider} / ${model.modelId}`,
value: { provider: model.provider, id: model.modelId } // Use id here for comparison
value: { provider: model.provider, id: model.modelId }
}));
if (allModelsForSetup.length === 0) {
@@ -1684,118 +1699,110 @@ function registerCommands(programInstance) {
process.exit(1);
}
// Function to find the index of the currently selected model ID
// Ensure it correctly searches the unfiltered selectableModels list
const findDefaultIndex = (roleModelId) => {
if (!roleModelId) return -1; // Handle cases where a role isn't set
return allModelsForSetup.findIndex(
(m) => m.value.id === roleModelId // Compare using the 'id' from the value object
);
};
// Helper to get research choices and default index
const getResearchChoicesAndDefault = () => {
const researchChoices = allModelsForSetup.filter((modelChoice) =>
// Helper to get choices and default index for a role
const getPromptData = (role, allowNone = false) => {
const roleChoices = allModelsForSetup.filter((modelChoice) =>
availableModelsForSetup
.find((m) => m.modelId === modelChoice.value.id)
?.allowedRoles?.includes('research')
?.allowedRoles?.includes(role)
);
const defaultIndex = researchChoices.findIndex(
(m) => m.value.id === currentModels.research?.modelId
);
return { choices: researchChoices, default: defaultIndex };
};
// Helper to get fallback choices and default index
const getFallbackChoicesAndDefault = () => {
const choices = [
{ name: 'None (disable fallback)', value: null },
new inquirer.Separator(),
...allModelsForSetup
];
const currentFallbackId = currentModels.fallback?.modelId;
let defaultIndex = 0; // Default to 'None'
if (currentFallbackId) {
const foundIndex = allModelsForSetup.findIndex(
(m) => m.value.id === currentFallbackId
);
if (foundIndex !== -1) {
defaultIndex = foundIndex + 2; // +2 because of 'None' and Separator
let choices = [...roleChoices];
let defaultIndex = -1;
const currentModelId = currentModels[role]?.modelId;
if (allowNone) {
choices = [
{ name: 'None (disable)', value: null },
new inquirer.Separator(),
...roleChoices
];
if (currentModelId) {
const foundIndex = roleChoices.findIndex(
(m) => m.value.id === currentModelId
);
defaultIndex = foundIndex !== -1 ? foundIndex + 2 : 0; // +2 for None and Separator
} else {
defaultIndex = 0; // Default to 'None'
}
} else {
if (currentModelId) {
defaultIndex = roleChoices.findIndex(
(m) => m.value.id === currentModelId
);
}
}
// Add Cancel option
const cancelOption = {
name: 'Cancel setup (q)',
value: '__CANCEL__'
};
choices = [cancelOption, new inquirer.Separator(), ...choices];
defaultIndex = defaultIndex !== -1 ? defaultIndex + 2 : 0; // +2 for Cancel and Separator
return { choices, default: defaultIndex };
};
const researchPromptData = getResearchChoicesAndDefault();
const fallbackPromptData = getFallbackChoicesAndDefault();
// Call the helper function for main model choices
const mainPromptData = getMainChoicesAndDefault();
// Add cancel option for all prompts
const cancelOption = {
name: 'Cancel setup (q)',
value: '__CANCEL__'
};
const mainModelChoices = [
cancelOption,
new inquirer.Separator(),
...mainPromptData.choices
];
const researchModelChoices = [
cancelOption,
new inquirer.Separator(),
...researchPromptData.choices
];
const fallbackModelChoices = [
cancelOption,
new inquirer.Separator(),
...fallbackPromptData.choices
];
// Add key press handler for 'q' to cancel
process.stdin.on('keypress', (str, key) => {
if (key.name === 'q') {
process.stdin.pause();
console.log(chalk.yellow('\nSetup canceled. No changes made.'));
process.exit(0);
}
});
// Ensure stdin is available and resume it if needed
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key) => {
if (key === 'q' || key === '\\u0003') {
// 'q' or Ctrl+C
console.log(
chalk.yellow('\\nSetup canceled. No changes made.')
);
process.exit(0);
}
});
console.log(
chalk.gray('Press "q" at any time to cancel the setup.')
);
}
console.log(chalk.gray('Press "q" at any time to cancel the setup.'));
// --- Generate choices using the helper ---
const mainPromptData = getPromptData('main');
const researchPromptData = getPromptData('research');
const fallbackPromptData = getPromptData('fallback', true); // Allow 'None' for fallback
const answers = await inquirer.prompt([
{
type: 'list',
name: 'mainModel',
message: 'Select the main model for generation/updates:',
choices: mainModelChoices,
default: mainPromptData.default + 2 // +2 for cancel option and separator
choices: mainPromptData.choices,
default: mainPromptData.default
},
{
type: 'list',
name: 'researchModel',
message: 'Select the research model:',
choices: researchModelChoices,
default: researchPromptData.default + 2, // +2 for cancel option and separator
when: (answers) => answers.mainModel !== '__CANCEL__'
choices: researchPromptData.choices,
default: researchPromptData.default,
when: (ans) => ans.mainModel !== '__CANCEL__'
},
{
type: 'list',
name: 'fallbackModel',
message: 'Select the fallback model (optional):',
choices: fallbackModelChoices,
default: fallbackPromptData.default + 2, // +2 for cancel option and separator
when: (answers) =>
answers.mainModel !== '__CANCEL__' &&
answers.researchModel !== '__CANCEL__'
choices: fallbackPromptData.choices,
default: fallbackPromptData.default,
when: (ans) =>
ans.mainModel !== '__CANCEL__' &&
ans.researchModel !== '__CANCEL__'
}
]);
// Clean up the keypress handler
process.stdin.removeAllListeners('keypress');
if (process.stdin.isTTY) {
process.stdin.pause();
process.stdin.removeAllListeners('data');
process.stdin.setRawMode(false);
}
// Check if user canceled at any point
if (
@@ -1803,19 +1810,25 @@ function registerCommands(programInstance) {
answers.researchModel === '__CANCEL__' ||
answers.fallbackModel === '__CANCEL__'
) {
console.log(chalk.yellow('\nSetup canceled. No changes made.'));
console.log(chalk.yellow('\\nSetup canceled. No changes made.'));
return;
}
// Apply changes using setModel
let setupSuccess = true;
let setupConfigModified = false;
const coreOptionsSetup = { projectRoot }; // Pass root for setup actions
if (
answers.mainModel &&
answers.mainModel?.id &&
answers.mainModel.id !== currentModels.main?.modelId
) {
const result = await setModel('main', answers.mainModel.id);
const result = await setModel(
'main',
answers.mainModel.id,
coreOptionsSetup
);
if (result.success) {
console.log(
chalk.blue(
@@ -1835,9 +1848,14 @@ function registerCommands(programInstance) {
if (
answers.researchModel &&
answers.researchModel?.id &&
answers.researchModel.id !== currentModels.research?.modelId
) {
const result = await setModel('research', answers.researchModel.id);
const result = await setModel(
'research',
answers.researchModel.id,
coreOptionsSetup
);
if (result.success) {
console.log(
chalk.blue(
@@ -1857,12 +1875,18 @@ function registerCommands(programInstance) {
// Set Fallback Model - Handle 'None' selection
const currentFallbackId = currentModels.fallback?.modelId;
const selectedFallbackId = answers.fallbackModel?.id; // Will be null if 'None' selected
const selectedFallbackValue = answers.fallbackModel; // Could be null or model object
const selectedFallbackId = selectedFallbackValue?.id; // Undefined if null
if (selectedFallbackId !== currentFallbackId) {
// Compare IDs
if (selectedFallbackId) {
// User selected a specific fallback model
const result = await setModel('fallback', selectedFallbackId);
const result = await setModel(
'fallback',
selectedFallbackId,
coreOptionsSetup
);
if (result.success) {
console.log(
chalk.blue(
@@ -1881,35 +1905,43 @@ function registerCommands(programInstance) {
} else if (currentFallbackId) {
// User selected 'None' but a fallback was previously set
// Need to explicitly clear it in the config file
const currentCfg = getConfig();
currentCfg.models.fallback = {
...currentCfg.models.fallback,
provider: undefined,
modelId: undefined
};
if (writeConfig(currentCfg)) {
console.log(chalk.blue('Fallback model disabled.'));
setupConfigModified = true;
const currentCfg = getConfig(projectRoot); // Pass root
if (currentCfg?.models?.fallback) {
// Check if fallback exists before clearing
currentCfg.models.fallback = {
...currentCfg.models.fallback,
provider: undefined,
modelId: undefined
};
if (writeConfig(currentCfg, projectRoot)) {
// Pass root
console.log(chalk.blue('Fallback model disabled.'));
setupConfigModified = true;
} else {
console.error(
chalk.red(
'Failed to disable fallback model in config file.'
)
);
setupSuccess = false;
}
} else {
console.error(
chalk.red('Failed to disable fallback model in config file.')
);
setupSuccess = false;
console.log(chalk.blue('Fallback model was already disabled.'));
}
}
// No action needed if fallback was already null/undefined and user selected None
}
if (setupSuccess && setupConfigModified) {
console.log(chalk.green.bold('\nModel setup complete!'));
console.log(chalk.green.bold('\\nModel setup complete!'));
} else if (setupSuccess && !setupConfigModified) {
console.log(
chalk.yellow('\nNo changes made to model configuration.')
chalk.yellow('\\nNo changes made to model configuration.')
);
} else if (!setupSuccess) {
console.error(
chalk.red(
'\nErrors occurred during model selection. Please review and try again.'
'\\nErrors occurred during model selection. Please review and try again.'
)
);
}
@@ -1917,9 +1949,8 @@ function registerCommands(programInstance) {
}
// --- Default: Display Current Configuration ---
// No longer need to check configModified here, as the set/setup logic returns early
// Fetch configuration using the core function
const result = await getModelConfiguration();
// Fetch configuration using the core function - PASS projectRoot
const result = await getModelConfiguration({ projectRoot });
if (!result.success) {
// Handle specific CONFIG_MISSING error gracefully

View File

@@ -79,15 +79,25 @@ class ConfigurationError extends Error {
function _loadAndValidateConfig(explicitRoot = null) {
const defaults = DEFAULTS; // Use the defined defaults
let rootToUse = explicitRoot;
let configSource = explicitRoot
? `explicit root (${explicitRoot})`
: 'defaults (no root provided yet)';
// If no explicit root is provided (e.g., during initial server load),
// return defaults immediately and silently.
if (!explicitRoot) {
return defaults;
// ---> If no explicit root, TRY to find it <---
if (!rootToUse) {
rootToUse = findProjectRoot();
if (rootToUse) {
configSource = `found root (${rootToUse})`;
} else {
// No root found, return defaults immediately
return defaults;
}
}
// ---> End find project root logic <---
// --- Proceed with loading from the provided explicitRoot ---
const configPath = path.join(explicitRoot, CONFIG_FILE_NAME);
// --- Proceed with loading from the determined rootToUse ---
const configPath = path.join(rootToUse, CONFIG_FILE_NAME);
let config = { ...defaults }; // Start with a deep copy of defaults
let configExists = false;
@@ -113,9 +123,10 @@ function _loadAndValidateConfig(explicitRoot = null) {
},
global: { ...defaults.global, ...parsedConfig?.global }
};
configSource = `file (${configPath})`; // Update source info
// --- Validation (Warn if file content is invalid) ---
// Only use console.warn here, as this part runs only when an explicitRoot *is* provided
// Use log.warn for consistency
if (!validateProvider(config.models.main.provider)) {
console.warn(
chalk.yellow(
@@ -152,17 +163,27 @@ function _loadAndValidateConfig(explicitRoot = null) {
)
);
config = { ...defaults }; // Reset to defaults on parse error
configSource = `defaults (parse error at ${configPath})`;
}
} else {
// Config file doesn't exist at the provided explicitRoot.
// Use console.warn because an explicit root *was* given.
console.warn(
chalk.yellow(
`Warning: ${CONFIG_FILE_NAME} not found at provided project root (${explicitRoot}). Using default configuration. Run 'task-master models --setup' to configure.`
)
);
// Config file doesn't exist at the determined rootToUse.
if (explicitRoot) {
// Only warn if an explicit root was *expected*.
console.warn(
chalk.yellow(
`Warning: ${CONFIG_FILE_NAME} not found at provided project root (${explicitRoot}). Using default configuration. Run 'task-master models --setup' to configure.`
)
);
} else {
console.warn(
chalk.yellow(
`Warning: ${CONFIG_FILE_NAME} not found at derived root (${rootToUse}). Using defaults.`
)
);
}
// Keep config as defaults
config = { ...defaults };
configSource = `defaults (file not found at ${configPath})`;
}
return config;
@@ -392,10 +413,11 @@ function isApiKeySet(providerName, session = null) {
* 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.
* @param {string|null} projectRoot - Optional explicit path to the project root.
* @returns {boolean} True if the key exists and is not a placeholder, false otherwise.
*/
function getMcpApiKeyStatus(providerName) {
const rootDir = findProjectRoot(); // Use existing root finding
function getMcpApiKeyStatus(providerName, projectRoot = null) {
const rootDir = projectRoot || findProjectRoot(); // Use existing root finding
if (!rootDir) {
console.warn(
chalk.yellow('Warning: Could not find project root to check mcp.json.')