feat: refactor Claude API Profiles to Claude Compatible Providers

- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system
This commit is contained in:
Stefan de Vogelaere
2026-01-20 19:13:34 +01:00
parent 47e6ed6a17
commit 2ceab3d65e
40 changed files with 3730 additions and 1019 deletions

View File

@@ -10,6 +10,10 @@ import type {
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
ClaudeCompatibleProvider,
PhaseModelKey,
PhaseModelEntry,
Credentials,
} from '@automaker/types';
import {
mergeAutoModePrompts,
@@ -364,6 +368,9 @@ export interface ActiveClaudeApiProfileResult {
* Checks project settings first for per-project overrides, then falls back to global settings.
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
*
* @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
* This function is kept for backward compatibility during migration.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @param projectPath - Optional project path for per-project override
@@ -427,3 +434,285 @@ export async function getActiveClaudeApiProfile(
return { profile: undefined, credentials: undefined };
}
}
// ============================================================================
// New Provider System Helpers
// ============================================================================
/** Result from getProviderById */
export interface ProviderByIdResult {
/** The provider, or undefined if not found */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: Credentials | undefined;
}
/**
* Get a ClaudeCompatibleProvider by its ID.
* Returns the provider configuration and credentials for API key resolution.
*
* @param providerId - The provider ID to look up
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to object with provider and credentials
*/
export async function getProviderById(
providerId: string,
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<ProviderByIdResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
const provider = providers.find((p) => p.id === providerId);
if (provider) {
if (provider.enabled === false) {
logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
} else {
logger.debug(`${logPrefix} Found provider: ${provider.name}`);
}
return { provider, credentials };
} else {
logger.warn(`${logPrefix} Provider not found: ${providerId}`);
return { provider: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load provider by ID:`, error);
return { provider: undefined, credentials: undefined };
}
}
/** Result from getPhaseModelWithOverrides */
export interface PhaseModelWithOverridesResult {
/** The resolved phase model entry */
phaseModel: PhaseModelEntry;
/** Whether a project override was applied */
isProjectOverride: boolean;
/** The provider if providerId is set and found */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
}
/**
* Get the phase model configuration for a specific phase, applying project overrides if available.
* Also resolves the provider if the phase model has a providerId.
*
* @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
* @param settingsService - Settings service instance
* @param projectPath - Optional project path for checking overrides
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to phase model with provider info
*/
export async function getPhaseModelWithOverrides(
phase: PhaseModelKey,
settingsService: SettingsService,
projectPath?: string,
logPrefix = '[SettingsHelper]'
): Promise<PhaseModelWithOverridesResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const globalPhaseModels = globalSettings.phaseModels || {};
// Start with global phase model
let phaseModel = globalPhaseModels[phase];
let isProjectOverride = false;
// Check for project override
if (projectPath) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const projectOverrides = projectSettings.phaseModelOverrides || {};
if (projectOverrides[phase]) {
phaseModel = projectOverrides[phase];
isProjectOverride = true;
logger.debug(`${logPrefix} Using project override for ${phase}`);
}
}
// If no phase model found, use a default
if (!phaseModel) {
phaseModel = { model: 'sonnet' };
logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`);
}
// Resolve provider if providerId is set
let provider: ClaudeCompatibleProvider | undefined;
if (phaseModel.providerId) {
const providers = globalSettings.claudeCompatibleProviders || [];
provider = providers.find((p) => p.id === phaseModel.providerId);
if (provider) {
if (provider.enabled === false) {
logger.warn(
`${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
);
provider = undefined;
} else {
logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
}
} else {
logger.warn(
`${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
);
}
}
return {
phaseModel,
isProjectOverride,
provider,
credentials,
};
} catch (error) {
logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
// Return a safe default
return {
phaseModel: { model: 'sonnet' },
isProjectOverride: false,
provider: undefined,
credentials: undefined,
};
}
}
/** Result from getProviderByModelId */
export interface ProviderByModelIdResult {
/** The provider that contains this model, or undefined if not found */
provider: ClaudeCompatibleProvider | undefined;
/** The model configuration if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
resolvedModel: string | undefined;
}
/**
* Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model.
* This is useful when you have a model string from the UI but need the provider config.
*
* Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
* when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
*
* @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to object with provider, model config, credentials, and resolved model
*/
export async function getProviderByModelId(
modelId: string,
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<ProviderByModelIdResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
// Search through all enabled providers for this model
for (const provider of providers) {
// Skip disabled providers
if (provider.enabled === false) {
continue;
}
// Check if this provider has the model
const modelConfig = provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
if (modelConfig) {
logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
// Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig.mapsToClaudeModel) {
// Import resolveModelString to convert alias to full model ID
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.info(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
return { provider, modelConfig, credentials, resolvedModel };
}
}
// Model not found in any provider
logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
return {
provider: undefined,
modelConfig: undefined,
credentials: undefined,
resolvedModel: undefined,
};
} catch (error) {
logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
return {
provider: undefined,
modelConfig: undefined,
credentials: undefined,
resolvedModel: undefined,
};
}
}
/**
* Get all enabled provider models for use in model dropdowns.
* Returns models from all enabled ClaudeCompatibleProviders.
*
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to array of provider models with their provider info
*/
export async function getAllProviderModels(
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<
Array<{
providerId: string;
providerName: string;
model: import('@automaker/types').ProviderModel;
}>
> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const providers = globalSettings.claudeCompatibleProviders || [];
const allModels: Array<{
providerId: string;
providerName: string;
model: import('@automaker/types').ProviderModel;
}> = [];
for (const provider of providers) {
// Skip disabled providers
if (provider.enabled === false) {
continue;
}
for (const model of provider.models || []) {
allModels.push({
providerId: provider.id,
providerName: provider.name,
model,
});
}
}
logger.debug(
`${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
);
return allModels;
} catch (error) {
logger.error(`${logPrefix} Failed to get all provider models:`, error);
return [];
}
}