diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 64f3caee9..9ecde776a 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -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 { + 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 { + 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 { + 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 []; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index e4c8ad790..cfb590932 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -14,8 +14,17 @@ import { getThinkingTokenBudget, validateBareModelId, type ClaudeApiProfile, + type ClaudeCompatibleProvider, type Credentials, } from '@automaker/types'; + +/** + * ProviderConfig - Union type for provider configuration + * + * Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider. + * Both share the same connection settings structure. + */ +type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; import type { ExecuteOptions, ProviderMessage, @@ -51,34 +60,48 @@ const ALLOWED_ENV_VARS = [ // System vars are always passed from process.env regardless of profile const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; +/** + * Check if the config is a ClaudeCompatibleProvider (new system) + * by checking for the 'models' array property + */ +function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider { + return 'models' in config && Array.isArray(config.models); +} + /** * Build environment for the SDK with only explicitly allowed variables. - * When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env). - * When no profile is provided, uses direct Anthropic API settings from process.env. + * When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env). + * When no provider is provided, uses direct Anthropic API settings from process.env. + * + * Supports both: + * - ClaudeCompatibleProvider (new system with models[] array) + * - ClaudeApiProfile (legacy system with modelMappings) * - * @param profile - Optional Claude API profile for alternative endpoint configuration + * @param providerConfig - Optional provider configuration for alternative endpoint * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource */ function buildEnv( - profile?: ClaudeApiProfile, + providerConfig?: ProviderConfig, credentials?: Credentials ): Record { const env: Record = {}; - if (profile) { - // Use profile configuration (clean switch - don't inherit non-system vars from process.env) - logger.debug('Building environment from Claude API profile:', { - name: profile.name, - apiKeySource: profile.apiKeySource ?? 'inline', + if (providerConfig) { + // Use provider configuration (clean switch - don't inherit non-system vars from process.env) + logger.debug('[buildEnv] Using provider configuration:', { + name: providerConfig.name, + baseUrl: providerConfig.baseUrl, + apiKeySource: providerConfig.apiKeySource ?? 'inline', + isNewProvider: isClaudeCompatibleProvider(providerConfig), }); // Resolve API key based on source strategy let apiKey: string | undefined; - const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat + const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat switch (source) { case 'inline': - apiKey = profile.apiKey; + apiKey = providerConfig.apiKey; break; case 'env': apiKey = process.env.ANTHROPIC_API_KEY; @@ -90,36 +113,40 @@ function buildEnv( // Warn if no API key found if (!apiKey) { - logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`); + logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`); } // Authentication - if (profile.useAuthToken) { + if (providerConfig.useAuthToken) { env['ANTHROPIC_AUTH_TOKEN'] = apiKey; } else { env['ANTHROPIC_API_KEY'] = apiKey; } // Endpoint configuration - env['ANTHROPIC_BASE_URL'] = profile.baseUrl; + env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl; + logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`); - if (profile.timeoutMs) { - env['API_TIMEOUT_MS'] = String(profile.timeoutMs); + if (providerConfig.timeoutMs) { + env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs); } - // Model mappings - if (profile.modelMappings?.haiku) { - env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku; - } - if (profile.modelMappings?.sonnet) { - env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet; - } - if (profile.modelMappings?.opus) { - env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus; + // Model mappings - only for legacy ClaudeApiProfile + // For ClaudeCompatibleProvider, the model is passed directly (no mapping needed) + if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) { + if (providerConfig.modelMappings.haiku) { + env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku; + } + if (providerConfig.modelMappings.sonnet) { + env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet; + } + if (providerConfig.modelMappings.opus) { + env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus; + } } // Traffic control - if (profile.disableNonessentialTraffic) { + if (providerConfig.disableNonessentialTraffic) { env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; } } else { @@ -184,9 +211,14 @@ export class ClaudeProvider extends BaseProvider { sdkSessionId, thinkingLevel, claudeApiProfile, + claudeCompatibleProvider, credentials, } = options; + // Determine which provider config to use + // claudeCompatibleProvider takes precedence over claudeApiProfile + const providerConfig = claudeCompatibleProvider || claudeApiProfile; + // Convert thinking level to token budget const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); @@ -197,9 +229,9 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, // Pass only explicitly allowed environment variables to SDK - // When a profile is active, uses profile settings (clean switch) - // When no profile, uses direct Anthropic API (from process.env or CLI OAuth) - env: buildEnv(claudeApiProfile, credentials), + // When a provider is active, uses provider settings (clean switch) + // When no provider, uses direct Anthropic API (from process.env or CLI OAuth) + env: buildEnv(providerConfig, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation @@ -244,6 +276,18 @@ export class ClaudeProvider extends BaseProvider { promptPayload = prompt; } + // Log the environment being passed to the SDK for debugging + const envForSdk = sdkOptions.env as Record; + logger.debug('[ClaudeProvider] SDK Configuration:', { + model: sdkOptions.model, + baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)', + hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'], + hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'], + providerName: providerConfig?.name || '(direct Anthropic)', + maxTurns: sdkOptions.maxTurns, + maxThinkingTokens: sdkOptions.maxThinkingTokens, + }); + // Execute via Claude Agent SDK try { const stream = query({ prompt: promptPayload, options: sdkOptions }); diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 6ffbed0f1..85c252351 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -21,6 +21,7 @@ import type { ThinkingLevel, ReasoningEffort, ClaudeApiProfile, + ClaudeCompatibleProvider, Credentials, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; @@ -56,9 +57,17 @@ export interface SimpleQueryOptions { readOnly?: boolean; /** Setting sources for CLAUDE.md loading */ settingSources?: Array<'user' | 'project' | 'local'>; - /** Active Claude API profile for alternative endpoint configuration */ + /** + * Active Claude API profile for alternative endpoint configuration + * @deprecated Use claudeCompatibleProvider instead + */ claudeApiProfile?: ClaudeApiProfile; - /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */ + /** + * Claude-compatible provider for alternative endpoint configuration. + * Takes precedence over claudeApiProfile if both are set. + */ + claudeCompatibleProvider?: ClaudeCompatibleProvider; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */ credentials?: Credentials; } @@ -131,7 +140,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { logger.debug(`Feature text block received (${text.length} chars)`); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 0de21cf52..0f826d76d 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -19,7 +19,7 @@ import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../lib/settings-helpers.js'; const logger = createLogger('SpecRegeneration'); @@ -96,20 +96,26 @@ ${prompts.appSpec.structuredSpecInstructions}`; '[SpecRegeneration]' ); - // Get model from phase settings - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'specGenerationModel', + settingsService, + projectPath, + '[SpecRegeneration]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel, + provider: undefined, + credentials: undefined, + }; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); - logger.info('Using model:', model); - - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[SpecRegeneration]', - projectPath - ); + logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API'); let responseText = ''; let structuredOutput: SpecOutput | null = null; @@ -143,7 +149,7 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Spec generation only reads code, we write the spec ourselves settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index aabeebf2e..af5139ddb 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -17,7 +17,7 @@ import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../lib/settings-helpers.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { @@ -155,17 +155,26 @@ export async function syncSpec( '[SpecSync]' ); - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'specGenerationModel', + settingsService, + projectPath, + '[SpecSync]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel, + provider: undefined, + credentials: undefined, + }; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[SpecSync]', - projectPath - ); + logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API'); // Use AI to analyze tech stack const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. @@ -195,7 +204,7 @@ Return ONLY this JSON format, no other text: thinkingLevel, readOnly: true, settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource onText: (text) => { logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index 04dc3b57b..0eac4b4c1 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -28,7 +28,7 @@ import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../lib/settings-helpers.js'; const featureLoader = new FeatureLoader(); @@ -121,18 +121,39 @@ export async function generateBacklogPlan( content: 'Generating plan with AI...', }); - // Get the model to use from settings or provided override + // Get the model to use from settings or provided override with provider info let effectiveModel = model; let thinkingLevel: ThinkingLevel | undefined; - if (!effectiveModel) { - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel; - const resolved = resolvePhaseModel(phaseModelEntry); + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let credentials: import('@automaker/types').Credentials | undefined; + + if (effectiveModel) { + // Use explicit override - just get credentials + credentials = await settingsService?.getCredentials(); + } else if (settingsService) { + // Use settings-based model with provider info + const phaseResult = await getPhaseModelWithOverrides( + 'backlogPlanningModel', + settingsService, + projectPath, + '[BacklogPlan]' + ); + const resolved = resolvePhaseModel(phaseResult.phaseModel); + effectiveModel = resolved.model; + thinkingLevel = resolved.thinkingLevel; + claudeCompatibleProvider = phaseResult.provider; + credentials = phaseResult.credentials; + } else { + // Fallback to defaults + const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel); effectiveModel = resolved.model; thinkingLevel = resolved.thinkingLevel; } - logger.info('[BacklogPlan] Using model:', effectiveModel); + logger.info( + '[BacklogPlan] Using model:', + effectiveModel, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); const provider = ProviderFactory.getProviderForModel(effectiveModel); // Strip provider prefix - providers expect bare model IDs @@ -165,13 +186,6 @@ ${userPrompt}`; finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt } - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[BacklogPlan]', - projectPath - ); - // Execute the query const stream = provider.executeQuery({ prompt: finalPrompt, @@ -184,7 +198,7 @@ ${userPrompt}`; settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, readOnly: true, // Plan generation only generates text, doesn't write files thinkingLevel, // Pass thinking level for extended thinking - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }); diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 0fd3c3492..2742530ba 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -22,7 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -156,21 +156,28 @@ ${contentToAnalyze}`; '[DescribeFile]' ); - // Get model from phase settings - const settings = await settingsService?.getGlobalSettings(); - logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2)); - const phaseModelEntry = - settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel; - logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry)); + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'fileDescriptionModel', + settingsService, + cwd, + '[DescribeFile]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.fileDescriptionModel, + provider: undefined, + credentials: undefined, + }; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); - logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); - - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[DescribeFile]', - cwd + logger.info( + `Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`, + provider ? `via provider: ${provider.name}` : 'direct API' ); // Use simpleQuery - provider abstraction handles routing to correct provider @@ -183,7 +190,7 @@ ${contentToAnalyze}`; thinkingLevel, readOnly: true, // File description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }); diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 0c05bc2a6..81a4cd096 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -22,7 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeImage'); @@ -274,24 +274,33 @@ export function createDescribeImageHandler( '[DescribeImage]' ); - // Get model from phase settings - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel; + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'imageDescriptionModel', + settingsService, + cwd, + '[DescribeImage]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.imageDescriptionModel, + provider: undefined, + credentials: undefined, + }; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); - logger.info(`[${requestId}] Using model: ${model}`); + logger.info( + `[${requestId}] Using model: ${model}`, + provider ? `via provider: ${provider.name}` : 'direct API' + ); // Get customized prompts from settings const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[DescribeImage]', - cwd - ); - // Build the instruction text from centralized prompts const instructionText = prompts.contextDescription.describeImagePrompt; @@ -333,7 +342,7 @@ export function createDescribeImageHandler( thinkingLevel, readOnly: true, // Image description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 2fe0f669d..9045a18de 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,10 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { - getPromptCustomization, - getActiveClaudeApiProfile, -} from '../../../lib/settings-helpers.js'; +import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js'; import { buildUserPrompt, isValidEnhancementMode, @@ -126,19 +123,35 @@ export function createEnhanceHandler( // Build the user prompt with few-shot examples const userPrompt = buildUserPrompt(validMode, trimmedText, true); - // Resolve the model - use the passed model, default to sonnet for quality - const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet); + // Check if the model is a provider model (like "GLM-4.5-Air") + // If so, get the provider config and resolved Claude model + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + let credentials = await settingsService?.getCredentials(); + + if (model && settingsService) { + const providerResult = await getProviderByModelId( + model, + settingsService, + '[EnhancePrompt]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + credentials = providerResult.credentials; + logger.info( + `Using provider "${providerResult.provider.name}" for model "${model}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } + + // Resolve the model - use provider resolved model, passed model, or default to sonnet + const resolvedModel = + providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet); logger.debug(`Using model: ${resolvedModel}`); - // Get active Claude API profile for alternative endpoint configuration - // Uses project-specific profile if projectPath provided, otherwise global - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[EnhancePrompt]', - projectPath - ); - // Use simpleQuery - provider abstraction handles routing to correct provider // The system prompt is combined with user prompt since some providers // don't have a separate system prompt concept @@ -150,8 +163,8 @@ export function createEnhanceHandler( allowedTools: [], thinkingLevel, readOnly: true, // Prompt enhancement only generates text, doesn't write files - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); const enhancedText = result.text; diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index d6519940d..4e5e0dcbe 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -10,10 +10,7 @@ import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { - getPromptCustomization, - getActiveClaudeApiProfile, -} from '../../../lib/settings-helpers.js'; +import { getPromptCustomization } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateTitle'); @@ -64,13 +61,8 @@ export function createGenerateTitleHandler( const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); const systemPrompt = prompts.titleGeneration.systemPrompt; - // Get active Claude API profile for alternative endpoint configuration - // Uses project-specific profile if projectPath provided, otherwise global - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[GenerateTitle]', - projectPath - ); + // Get credentials for API calls (uses hardcoded haiku model, no phase setting) + const credentials = await settingsService?.getCredentials(); const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; @@ -81,7 +73,6 @@ export function createGenerateTitleHandler( cwd: process.cwd(), maxTurns: 1, allowedTools: [], - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }); diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 699b7e460..10465829e 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -37,7 +37,7 @@ import { import { getPromptCustomization, getAutoLoadClaudeMdSetting, - getActiveClaudeApiProfile, + getProviderByModelId, } from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, @@ -167,19 +167,33 @@ ${basePrompt}`; } } - logger.info(`Using model: ${model}`); + // Check if the model is a provider model (like "GLM-4.5-Air") + // If so, get the provider config and resolved Claude model + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + let credentials = await settingsService?.getCredentials(); + + if (settingsService) { + const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]'); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + credentials = providerResult.credentials; + logger.info( + `Using provider "${providerResult.provider.name}" for model "${model}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[IssueValidation]', - projectPath - ); + // Use provider resolved model if available, otherwise use original model + const effectiveModel = providerResolvedModel || (model as string); + logger.info(`Using model: ${effectiveModel}`); // Use streamingQuery with event callbacks const result = await streamingQuery({ prompt: finalPrompt, - model: model as string, + model: effectiveModel, cwd: projectPath, systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined, abortController, @@ -187,7 +201,7 @@ ${basePrompt}`; reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 7a21af6f6..49867c5c1 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -18,7 +18,7 @@ import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, - getActiveClaudeApiProfile, + getPhaseModelWithOverrides, } from '../../lib/settings-helpers.js'; const logger = createLogger('Suggestions'); @@ -171,11 +171,12 @@ ${prompts.suggestions.baseTemplate}`; '[Suggestions]' ); - // Get model from phase settings (AI Suggestions = suggestionsModel) + // Get model from phase settings with provider info (AI Suggestions = suggestionsModel) // Use override if provided, otherwise fall back to settings - const settings = await settingsService?.getGlobalSettings(); let model: string; let thinkingLevel: ThinkingLevel | undefined; + let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let credentials: import('@automaker/types').Credentials | undefined; if (modelOverride) { // Use explicit override - resolve the model string @@ -185,22 +186,32 @@ ${prompts.suggestions.baseTemplate}`; }); model = resolved.model; thinkingLevel = resolved.thinkingLevel; + // For overrides, just get credentials without a specific provider + credentials = await settingsService?.getCredentials(); + } else if (settingsService) { + // Use settings-based model with provider info + const phaseResult = await getPhaseModelWithOverrides( + 'suggestionsModel', + settingsService, + projectPath, + '[Suggestions]' + ); + const resolved = resolvePhaseModel(phaseResult.phaseModel); + model = resolved.model; + thinkingLevel = resolved.thinkingLevel; + provider = phaseResult.provider; + credentials = phaseResult.credentials; } else { - // Use settings-based model - const phaseModelEntry = - settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel; - const resolved = resolvePhaseModel(phaseModelEntry); + // Fallback to defaults + const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel); model = resolved.model; thinkingLevel = resolved.thinkingLevel; } - logger.info('[Suggestions] Using model:', model); - - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[Suggestions]', - projectPath + logger.info( + '[Suggestions] Using model:', + model, + provider ? `via provider: ${provider.name}` : 'direct API' ); let responseText = ''; @@ -234,7 +245,7 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Suggestions only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index 1b504f6fb..68ae8cc44 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -17,7 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getErrorMessage, logError } from '../common.js'; -import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js'; +import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); const execAsync = promisify(exec); @@ -157,26 +157,35 @@ export function createGenerateCommitMessageHandler( const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; - // Get model from phase settings - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider: claudeCompatibleProvider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'commitMessageModel', + settingsService, + worktreePath, + '[GenerateCommitMessage]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.commitMessageModel, + provider: undefined, + credentials: undefined, + }; const { model } = resolvePhaseModel(phaseModelEntry); - logger.info(`Using model for commit message: ${model}`); + logger.info( + `Using model for commit message: ${model}`, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); // Get the effective system prompt (custom or default) const systemPrompt = await getSystemPrompt(settingsService); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - settingsService, - '[GenerateCommitMessage]', - worktreePath - ); - // Get provider for the model type - const provider = ProviderFactory.getProviderForModel(model); + const aiProvider = ProviderFactory.getProviderForModel(model); const bareModel = stripProviderPrefix(model); // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation @@ -185,10 +194,10 @@ export function createGenerateCommitMessageHandler( : userPrompt; const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; - logger.info(`Using ${provider.getName()} provider for model: ${model}`); + logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`); let responseText = ''; - const stream = provider.executeQuery({ + const stream = aiProvider.executeQuery({ prompt: effectivePrompt, model: bareModel, cwd: worktreePath, @@ -196,7 +205,7 @@ export function createGenerateCommitMessageHandler( maxTurns: 1, allowedTools: [], readOnly: true, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 0b89a2bd8..09c919793 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -29,7 +29,7 @@ import { getSkillsConfiguration, getSubagentsConfiguration, getCustomSubagents, - getActiveClaudeApiProfile, + getProviderByModelId, } from '../lib/settings-helpers.js'; interface Message { @@ -275,12 +275,29 @@ export class AgentService { ? await getCustomSubagents(this.settingsService, effectiveWorkDir) : undefined; - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - this.settingsService, - '[AgentService]', - effectiveWorkDir - ); + // Get credentials for API calls + const credentials = await this.settingsService?.getCredentials(); + + // Try to find a provider for the model (if it's a provider model like "GLM-4.7") + // This allows users to select provider models in the Agent Runner UI + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + const requestedModel = model || session.model; + if (requestedModel && this.settingsService) { + const providerResult = await getProviderByModelId( + requestedModel, + this.settingsService, + '[AgentService]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + this.logger.info( + `[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection @@ -307,10 +324,16 @@ export class AgentService { // Use thinking level and reasoning effort from request, or fall back to session's stored values const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort; + + // When using a provider model, use the resolved Claude model (from mapsToClaudeModel) + // e.g., "GLM-4.5-Air" -> "claude-haiku-4-5" + const modelForSdk = providerResolvedModel || model; + const sessionModelForSdk = providerResolvedModel ? undefined : session.model; + const sdkOptions = createChatOptions({ cwd: effectiveWorkDir, - model: model, - sessionModel: session.model, + model: modelForSdk, + sessionModel: sessionModelForSdk, systemPrompt: combinedSystemPrompt, abortController: session.abortController!, autoLoadClaudeMd, @@ -386,8 +409,8 @@ export class AgentService { agents: customSubagents, // Pass custom subagents for task delegation thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.) }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9eeefc142..a2d7f1af4 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -68,7 +68,8 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, - getActiveClaudeApiProfile, + getProviderByModelId, + getPhaseModelWithOverrides, } from '../lib/settings-helpers.js'; import { getNotificationService } from './notification-service.js'; @@ -2312,13 +2313,30 @@ Address the follow-up instructions above. Review the previous work and make the Format your response as a structured markdown document.`; try { - // Get model from phase settings - const settings = await this.settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.projectAnalysisModel || DEFAULT_PHASE_MODELS.projectAnalysisModel; + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider: analysisClaudeProvider, + credentials, + } = this.settingsService + ? await getPhaseModelWithOverrides( + 'projectAnalysisModel', + this.settingsService, + projectPath, + '[AutoMode]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.projectAnalysisModel, + provider: undefined, + credentials: undefined, + }; const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = resolvePhaseModel(phaseModelEntry); - logger.info('Using model for project analysis:', analysisModel); + logger.info( + 'Using model for project analysis:', + analysisModel, + analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API' + ); const provider = ProviderFactory.getProviderForModel(analysisModel); @@ -2340,13 +2358,6 @@ Format your response as a structured markdown document.`; thinkingLevel: analysisThinkingLevel, }); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - this.settingsService, - '[AutoMode]', - projectPath - ); - const options: ExecuteOptions = { prompt, model: sdkOptions.model ?? analysisModel, @@ -2356,8 +2367,8 @@ Format your response as a structured markdown document.`; abortController, settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration }; const stream = provider.executeQuery(options); @@ -3398,16 +3409,37 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - this.settingsService, - '[AutoMode]', - finalProjectPath - ); + // Get credentials for API calls (model comes from request, no phase model) + const credentials = await this.settingsService?.getCredentials(); + + // Try to find a provider for the model (if it's a provider model like "GLM-4.7") + // This allows users to select provider models in the Auto Mode / Feature execution + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + if (finalModel && this.settingsService) { + const providerResult = await getProviderByModelId( + finalModel, + this.settingsService, + '[AutoMode]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + logger.info( + `[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } + + // Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel + const effectiveBareModel = providerResolvedModel + ? stripProviderPrefix(providerResolvedModel) + : bareModel; const executeOptions: ExecuteOptions = { prompt: promptContent, - model: bareModel, + model: effectiveBareModel, maxTurns: maxTurns, cwd: workDir, allowedTools: allowedTools, @@ -3416,8 +3448,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.) }; // Execute via provider @@ -3723,8 +3755,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); let revisionText = ''; @@ -3872,8 +3904,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); let taskOutput = ''; @@ -3972,8 +4004,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); for await (const msg of continuationStream) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index bcb469b16..419af47c2 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; import { stripProviderPrefix } from '@automaker/types'; -import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; const logger = createLogger('IdeationService'); @@ -223,12 +223,8 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - this.settingsService, - '[IdeationService]', - projectPath - ); + // Get credentials for API calls (uses hardcoded model, no phase setting) + const credentials = await this.settingsService?.getCredentials(); const executeOptions: ExecuteOptions = { prompt: message, @@ -239,7 +235,6 @@ export class IdeationService { maxTurns: 1, // Single turn for ideation abortController: activeSession.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }; @@ -687,12 +682,8 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); - // Get active Claude API profile for alternative endpoint configuration - const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( - this.settingsService, - '[IdeationService]', - projectPath - ); + // Get credentials for API calls (uses hardcoded model, no phase setting) + const credentials = await this.settingsService?.getCredentials(); const executeOptions: ExecuteOptions = { prompt: prompt.prompt, @@ -704,7 +695,6 @@ export class IdeationService { // Disable all tools - we just want text generation, not codebase analysis allowedTools: [], abortController: new AbortController(), - claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource }; diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 2cfb78c4d..8c760c700 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -31,6 +31,9 @@ import type { WorktreeInfo, PhaseModelConfig, PhaseModelEntry, + ClaudeApiProfile, + ClaudeCompatibleProvider, + ProviderModel, } from '../types/settings.js'; import { DEFAULT_GLOBAL_SETTINGS, @@ -206,6 +209,28 @@ export class SettingsService { needsSave = true; } + // Migration v5 -> v6: Convert claudeApiProfiles to claudeCompatibleProviders + // The new system uses a models[] array instead of modelMappings, and removes + // the "active profile" concept - models are selected directly in phase model configs. + if (storedVersion < 6) { + const legacyProfiles = settings.claudeApiProfiles || []; + if ( + legacyProfiles.length > 0 && + (!result.claudeCompatibleProviders || result.claudeCompatibleProviders.length === 0) + ) { + logger.info( + `Migration v5->v6: Converting ${legacyProfiles.length} Claude API profile(s) to compatible providers` + ); + result.claudeCompatibleProviders = this.migrateProfilesToProviders(legacyProfiles); + } + // Remove the deprecated activeClaudeApiProfileId field + if (result.activeClaudeApiProfileId) { + logger.info('Migration v5->v6: Removing deprecated activeClaudeApiProfileId'); + delete result.activeClaudeApiProfileId; + } + needsSave = true; + } + // Update version if any migration occurred if (needsSave) { result.version = SETTINGS_VERSION; @@ -290,6 +315,139 @@ export class SettingsService { }; } + /** + * Migrate ClaudeApiProfiles to ClaudeCompatibleProviders + * + * Converts the legacy profile format (with modelMappings) to the new + * provider format (with models[] array). Each model mapping entry becomes + * a ProviderModel with appropriate tier assignment. + * + * @param profiles - Legacy ClaudeApiProfile array + * @returns Array of ClaudeCompatibleProvider + */ + private migrateProfilesToProviders(profiles: ClaudeApiProfile[]): ClaudeCompatibleProvider[] { + return profiles.map((profile): ClaudeCompatibleProvider => { + // Convert modelMappings to models array + const models: ProviderModel[] = []; + + if (profile.modelMappings) { + // Haiku mapping + if (profile.modelMappings.haiku) { + models.push({ + id: profile.modelMappings.haiku, + displayName: this.inferModelDisplayName(profile.modelMappings.haiku, 'haiku'), + mapsToClaudeModel: 'haiku', + }); + } + // Sonnet mapping + if (profile.modelMappings.sonnet) { + models.push({ + id: profile.modelMappings.sonnet, + displayName: this.inferModelDisplayName(profile.modelMappings.sonnet, 'sonnet'), + mapsToClaudeModel: 'sonnet', + }); + } + // Opus mapping + if (profile.modelMappings.opus) { + models.push({ + id: profile.modelMappings.opus, + displayName: this.inferModelDisplayName(profile.modelMappings.opus, 'opus'), + mapsToClaudeModel: 'opus', + }); + } + } + + // Infer provider type from base URL or name + const providerType = this.inferProviderType(profile); + + return { + id: profile.id, + name: profile.name, + providerType, + enabled: true, + baseUrl: profile.baseUrl, + apiKeySource: profile.apiKeySource ?? 'inline', + apiKey: profile.apiKey, + useAuthToken: profile.useAuthToken, + timeoutMs: profile.timeoutMs, + disableNonessentialTraffic: profile.disableNonessentialTraffic, + models, + }; + }); + } + + /** + * Infer a display name for a model based on its ID and tier + * + * @param modelId - The raw model ID + * @param tier - The tier hint (haiku/sonnet/opus) + * @returns A user-friendly display name + */ + private inferModelDisplayName(modelId: string, tier: 'haiku' | 'sonnet' | 'opus'): string { + // Common patterns in model IDs + const lowerModelId = modelId.toLowerCase(); + + // GLM models + if (lowerModelId.includes('glm')) { + return modelId.replace(/-/g, ' ').replace(/glm/i, 'GLM'); + } + + // MiniMax models + if (lowerModelId.includes('minimax')) { + return modelId.replace(/-/g, ' ').replace(/minimax/i, 'MiniMax'); + } + + // Claude models via OpenRouter or similar + if (lowerModelId.includes('claude')) { + return modelId; + } + + // Default: use model ID as display name with tier in parentheses + return `${modelId} (${tier})`; + } + + /** + * Infer provider type from profile configuration + * + * @param profile - The legacy profile + * @returns The inferred provider type + */ + private inferProviderType(profile: ClaudeApiProfile): ClaudeCompatibleProvider['providerType'] { + const baseUrl = profile.baseUrl.toLowerCase(); + const name = profile.name.toLowerCase(); + + // Check URL patterns + if (baseUrl.includes('z.ai') || baseUrl.includes('zhipuai')) { + return 'glm'; + } + if (baseUrl.includes('minimax')) { + return 'minimax'; + } + if (baseUrl.includes('openrouter')) { + return 'openrouter'; + } + if (baseUrl.includes('anthropic.com')) { + return 'anthropic'; + } + + // Check name patterns + if (name.includes('glm') || name.includes('zhipu')) { + return 'glm'; + } + if (name.includes('minimax')) { + return 'minimax'; + } + if (name.includes('openrouter')) { + return 'openrouter'; + } + if (name.includes('anthropic') || name.includes('direct')) { + return 'anthropic'; + } + + // Default to custom + return 'custom'; + } + /** * Migrate model-related settings to canonical format * @@ -413,6 +571,7 @@ export class SettingsService { ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); ignoreEmptyArrayOverwrite('claudeApiProfiles'); + // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers // Empty object overwrite guard const ignoreEmptyObjectOverwrite = (key: K): void => { @@ -658,6 +817,16 @@ export class SettingsService { delete updated.activeClaudeApiProfileId; } + // Handle phaseModelOverrides special cases: + // - "__CLEAR__" marker means delete the key (use global settings for all phases) + // - object means partial overrides for specific phases + if ( + 'phaseModelOverrides' in updates && + (updates as Record).phaseModelOverrides === '__CLEAR__' + ) { + delete updated.phaseModelOverrides; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index 98bce97f3..6863b314a 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -23,6 +23,16 @@ export type { PhaseModelConfig, PhaseModelKey, PhaseModelEntry, + // Claude-compatible provider types + ApiKeySource, + ClaudeCompatibleProviderType, + ClaudeModelAlias, + ProviderModel, + ClaudeCompatibleProvider, + ClaudeCompatibleProviderTemplate, + // Legacy profile types (deprecated) + ClaudeApiProfile, + ClaudeApiProfileTemplate, } from '@automaker/types'; export { diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 5f0b66338..984c9a2ad 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -523,6 +523,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { } } + // Check for ClaudeCompatibleProvider model patterns (GLM, MiniMax, etc.) + // These are model IDs like "GLM-4.5-Air", "GLM-4.7", "MiniMax-M2.1" + if (modelStr.includes('glm')) { + return 'glm'; + } + if (modelStr.includes('minimax')) { + return 'minimax'; + } + // Check for Cursor-specific models with underlying providers if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) { return 'anthropic'; diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index bdbe8a1c7..e29564d1c 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react'; +import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; export interface ProjectNavigationItem { @@ -12,6 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'theme', label: 'Theme', icon: Palette }, - { id: 'claude', label: 'Claude', icon: Bot }, + { id: 'claude', label: 'Models', icon: Workflow }, { id: 'danger', label: 'Danger Zone', icon: AlertTriangle }, ]; diff --git a/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx new file mode 100644 index 000000000..66e2cb0e3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx @@ -0,0 +1,356 @@ +import { useState, useMemo } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Project } from '@/lib/electron'; +import type { + PhaseModelKey, + PhaseModelEntry, + ClaudeCompatibleProvider, + ClaudeModelAlias, +} from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; + +interface ProjectBulkReplaceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + project: Project; +} + +// Phase display names for preview +const PHASE_LABELS: Record = { + enhancementModel: 'Feature Enhancement', + fileDescriptionModel: 'File Descriptions', + imageDescriptionModel: 'Image Descriptions', + commitMessageModel: 'Commit Messages', + validationModel: 'GitHub Issue Validation', + specGenerationModel: 'App Specification', + featureGenerationModel: 'Feature Generation', + backlogPlanningModel: 'Backlog Planning', + projectAnalysisModel: 'Project Analysis', + suggestionsModel: 'AI Suggestions', + memoryExtractionModel: 'Memory Extraction', +}; + +const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[]; + +// Claude model display names +const CLAUDE_MODEL_DISPLAY: Record = { + haiku: 'Claude Haiku', + sonnet: 'Claude Sonnet', + opus: 'Claude Opus', +}; + +export function ProjectBulkReplaceDialog({ + open, + onOpenChange, + project, +}: ProjectBulkReplaceDialogProps) { + const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore(); + const [selectedProvider, setSelectedProvider] = useState('anthropic'); + + // Get project-level overrides + const projectOverrides = project.phaseModelOverrides || {}; + + // Get enabled providers + const enabledProviders = useMemo(() => { + return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false); + }, [claudeCompatibleProviders]); + + // Build provider options for the dropdown + const providerOptions = useMemo(() => { + const options: Array<{ id: string; name: string; isNative: boolean }> = [ + { id: 'anthropic', name: 'Anthropic Direct', isNative: true }, + ]; + + enabledProviders.forEach((provider) => { + options.push({ + id: provider.id, + name: provider.name, + isNative: false, + }); + }); + + return options; + }, [enabledProviders]); + + // Get the selected provider config (if custom) + const selectedProviderConfig = useMemo(() => { + if (selectedProvider === 'anthropic') return null; + return enabledProviders.find((p) => p.id === selectedProvider); + }, [selectedProvider, enabledProviders]); + + // Get the Claude model alias from a PhaseModelEntry + const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => { + // Check if model string directly matches a Claude alias + if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku'; + if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet'; + if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus'; + + // If it's a provider model, look up the mapping + if (entry.providerId) { + const provider = enabledProviders.find((p) => p.id === entry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === entry.model); + if (model?.mapsToClaudeModel) { + return model.mapsToClaudeModel; + } + } + } + + // Default to sonnet + return 'sonnet'; + }; + + // Find the model from provider that maps to a specific Claude model + const findModelForClaudeAlias = ( + provider: ClaudeCompatibleProvider | null, + claudeAlias: ClaudeModelAlias, + phase: PhaseModelKey + ): PhaseModelEntry => { + if (!provider) { + // Anthropic Direct - reset to default phase model (includes correct thinking levels) + return DEFAULT_PHASE_MODELS[phase]; + } + + // Find model that maps to this Claude alias + const models = provider.models || []; + const match = models.find((m) => m.mapsToClaudeModel === claudeAlias); + + if (match) { + return { providerId: provider.id, model: match.id }; + } + + // Fallback: use first model if no match + if (models.length > 0) { + return { providerId: provider.id, model: models[0].id }; + } + + // Ultimate fallback to native Claude model + return { model: claudeAlias }; + }; + + // Generate preview of changes + const preview = useMemo(() => { + return ALL_PHASES.map((phase) => { + // Current effective value (project override or global) + const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]; + const currentEntry = projectOverrides[phase] || globalEntry; + const claudeAlias = getClaudeModelAlias(currentEntry); + const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase); + + // Get display names + const getCurrentDisplay = (): string => { + if (currentEntry.providerId) { + const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === currentEntry.model); + return model?.displayName || currentEntry.model; + } + } + return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; + }; + + const getNewDisplay = (): string => { + if (newEntry.providerId && selectedProviderConfig) { + const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); + return model?.displayName || newEntry.model; + } + return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; + }; + + const isChanged = + currentEntry.model !== newEntry.model || + currentEntry.providerId !== newEntry.providerId || + currentEntry.thinkingLevel !== newEntry.thinkingLevel; + + return { + phase, + label: PHASE_LABELS[phase], + claudeAlias, + currentDisplay: getCurrentDisplay(), + newDisplay: getNewDisplay(), + newEntry, + isChanged, + }; + }); + }, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]); + + // Count how many will change + const changeCount = preview.filter((p) => p.isChanged).length; + + // Apply the bulk replace as project overrides + const handleApply = () => { + preview.forEach(({ phase, newEntry, isChanged }) => { + if (isChanged) { + setProjectPhaseModelOverride(project.id, phase, newEntry); + } + }); + onOpenChange(false); + }; + + // Check if provider has all 3 Claude model mappings + const providerModelCoverage = useMemo(() => { + if (selectedProvider === 'anthropic') { + return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true }; + } + if (!selectedProviderConfig) { + return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false }; + } + const models = selectedProviderConfig.models || []; + const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku'); + const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet'); + const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus'); + return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus }; + }, [selectedProvider, selectedProviderConfig]); + + const providerHasModels = + selectedProvider === 'anthropic' || + (selectedProviderConfig && selectedProviderConfig.models?.length > 0); + + return ( + + + + Bulk Replace Models (Project Override) + + Set project-level overrides for all phases to use models from a specific provider. This + only affects this project. + + + +
+ {/* Provider selector */} +
+ + +
+ + {/* Warning if provider has no models */} + {!providerHasModels && ( +
+
+ + This provider has no models configured. +
+
+ )} + + {/* Warning if provider doesn't have all 3 mappings */} + {providerHasModels && !providerModelCoverage.complete && ( +
+
+ + + This provider is missing mappings for:{' '} + {[ + !providerModelCoverage.hasHaiku && 'Haiku', + !providerModelCoverage.hasSonnet && 'Sonnet', + !providerModelCoverage.hasOpus && 'Opus', + ] + .filter(Boolean) + .join(', ')} + +
+
+ )} + + {/* Preview of changes */} + {providerHasModels && ( +
+
+ + + {changeCount} of {ALL_PHASES.length} will be overridden + +
+
+ + + + + + + + + + + {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + + + + + + + ))} + +
PhaseCurrent + New Override +
{label}{currentDisplay} + {isChanged ? ( + + ) : ( + + )} + + + {newDisplay} + +
+
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx index 3ae17a838..d5e313127 100644 --- a/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx @@ -63,7 +63,7 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {

Claude not configured

- Enable Claude and configure API profiles in global settings to use per-project profiles. + Enable Claude and configure providers in global settings to use per-project overrides.

); @@ -95,12 +95,10 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
-

- Claude API Profile -

+

Claude Provider

- Override the Claude API profile for this project only. + Override the Claude provider for this project only.

diff --git a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx new file mode 100644 index 000000000..33d3c4de3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx @@ -0,0 +1,362 @@ +import { useState } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Project } from '@/lib/electron'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; +import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog'; +import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; + +interface ProjectModelsSectionProps { + project: Project; +} + +interface PhaseConfig { + key: PhaseModelKey; + label: string; + description: string; +} + +const QUICK_TASKS: PhaseConfig[] = [ + { + key: 'enhancementModel', + label: 'Feature Enhancement', + description: 'Improves feature names and descriptions', + }, + { + key: 'fileDescriptionModel', + label: 'File Descriptions', + description: 'Generates descriptions for context files', + }, + { + key: 'imageDescriptionModel', + label: 'Image Descriptions', + description: 'Analyzes and describes context images', + }, + { + key: 'commitMessageModel', + label: 'Commit Messages', + description: 'Generates git commit messages from diffs', + }, +]; + +const VALIDATION_TASKS: PhaseConfig[] = [ + { + key: 'validationModel', + label: 'GitHub Issue Validation', + description: 'Validates and improves GitHub issues', + }, +]; + +const GENERATION_TASKS: PhaseConfig[] = [ + { + key: 'specGenerationModel', + label: 'App Specification', + description: 'Generates full application specifications', + }, + { + key: 'featureGenerationModel', + label: 'Feature Generation', + description: 'Creates features from specifications', + }, + { + key: 'backlogPlanningModel', + label: 'Backlog Planning', + description: 'Reorganizes and prioritizes backlog', + }, + { + key: 'projectAnalysisModel', + label: 'Project Analysis', + description: 'Analyzes project structure for suggestions', + }, + { + key: 'suggestionsModel', + label: 'AI Suggestions', + description: 'Model for feature, refactoring, security, and performance suggestions', + }, +]; + +const MEMORY_TASKS: PhaseConfig[] = [ + { + key: 'memoryExtractionModel', + label: 'Memory Extraction', + description: 'Extracts learnings from completed agent sessions', + }, +]; + +const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS]; + +function PhaseOverrideItem({ + phase, + project, + globalValue, + projectOverride, +}: { + phase: PhaseConfig; + project: Project; + globalValue: PhaseModelEntry; + projectOverride?: PhaseModelEntry; +}) { + const { setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore(); + + const hasOverride = !!projectOverride; + const effectiveValue = projectOverride || globalValue; + + // Get display name for a model + const getModelDisplayName = (entry: PhaseModelEntry): string => { + if (entry.providerId) { + const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === entry.model); + if (model) { + return `${model.displayName} (${provider.name})`; + } + } + } + // Default to model ID for built-in models + const modelMap: Record = { + haiku: 'Claude Haiku', + sonnet: 'Claude Sonnet', + opus: 'Claude Opus', + }; + return modelMap[entry.model] || entry.model; + }; + + const handleClearOverride = () => { + setProjectPhaseModelOverride(project.id, phase.key, null); + }; + + const handleSetOverride = (entry: PhaseModelEntry) => { + setProjectPhaseModelOverride(project.id, phase.key, entry); + }; + + return ( +
+
+
+

{phase.label}

+ {hasOverride ? ( + + Override + + ) : ( + + + Global + + )} +
+

{phase.description}

+ {hasOverride && ( +

+ Using: {getModelDisplayName(effectiveValue)} +

+ )} + {!hasOverride && ( +

+ Using global: {getModelDisplayName(globalValue)} +

+ )} +
+ +
+ {hasOverride && ( + + )} + +
+
+ ); +} + +function PhaseGroup({ + title, + subtitle, + phases, + project, +}: { + title: string; + subtitle: string; + phases: PhaseConfig[]; + project: Project; +}) { + const { phaseModels } = useAppStore(); + const projectOverrides = project.phaseModelOverrides || {}; + + return ( +
+
+

{title}

+

{subtitle}

+
+
+ {phases.map((phase) => ( + + ))} +
+
+ ); +} + +export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { + const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } = + useAppStore(); + const [showBulkReplace, setShowBulkReplace] = useState(false); + + // Count how many overrides are set + const overrideCount = Object.keys(project.phaseModelOverrides || {}).length; + + // Check if Claude is available + const isClaudeDisabled = disabledProviders.includes('claude'); + + // Check if there are any enabled ClaudeCompatibleProviders + const hasEnabledProviders = + claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false); + + if (isClaudeDisabled) { + return ( +
+ +

Claude not configured

+

+ Enable Claude in global settings to configure per-project model overrides. +

+
+ ); + } + + const handleClearAll = () => { + clearAllProjectPhaseModelOverrides(project.id); + }; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

+ Model Overrides +

+

+ Override AI models for this project only +

+
+
+
+ {hasEnabledProviders && ( + + )} + {overrideCount > 0 && ( + + )} +
+
+
+ + {/* Bulk Replace Dialog */} + + + {/* Info Banner */} +
+
+
+ + Per-Phase Overrides +
+ Override specific phases to use different models for this project. Phases without + overrides use the global settings. +
+
+ + {/* Content */} +
+ {/* Quick Tasks */} + + + {/* Validation Tasks */} + + + {/* Generation Tasks */} + + + {/* Memory Tasks */} + +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index f511bfc05..75548f663 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; -import { ProjectClaudeSection } from './project-claude-section'; +import { ProjectModelsSection } from './project-models-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog'; import { ProjectSettingsNavigation } from './components/project-settings-navigation'; @@ -86,7 +86,7 @@ export function ProjectSettingsView() { case 'worktrees': return ; case 'claude': - return ; + return ; case 'danger': return ( (
- {/* Anthropic-specific profile info */} + {/* Anthropic-specific provider info */} {provider.key === 'anthropic' && (
@@ -113,20 +113,19 @@ export function ApiKeysSection() {

- Using Claude API Profiles? + Using Claude Compatible Providers? {' '} - Create a profile in{' '} - AI Providers → Claude with{' '} + Add a provider in AI Providers → Claude{' '} + with{' '} credentials {' '} as the API key source to use this key.

- For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile - with{' '} + For alternative providers (z.AI GLM, MiniMax, OpenRouter), add a provider with{' '} inline{' '} - key source and enter the provider's API key directly in the profile. + key source and enter the provider's API key directly.

diff --git a/apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx b/apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx new file mode 100644 index 000000000..aafd383d4 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx @@ -0,0 +1,343 @@ +import { useState, useMemo } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { + PhaseModelKey, + PhaseModelEntry, + ClaudeCompatibleProvider, + ClaudeModelAlias, +} from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; + +interface BulkReplaceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// Phase display names for preview +const PHASE_LABELS: Record = { + enhancementModel: 'Feature Enhancement', + fileDescriptionModel: 'File Descriptions', + imageDescriptionModel: 'Image Descriptions', + commitMessageModel: 'Commit Messages', + validationModel: 'GitHub Issue Validation', + specGenerationModel: 'App Specification', + featureGenerationModel: 'Feature Generation', + backlogPlanningModel: 'Backlog Planning', + projectAnalysisModel: 'Project Analysis', + suggestionsModel: 'AI Suggestions', + memoryExtractionModel: 'Memory Extraction', +}; + +const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[]; + +// Claude model display names +const CLAUDE_MODEL_DISPLAY: Record = { + haiku: 'Claude Haiku', + sonnet: 'Claude Sonnet', + opus: 'Claude Opus', +}; + +export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) { + const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore(); + const [selectedProvider, setSelectedProvider] = useState('anthropic'); + + // Get enabled providers + const enabledProviders = useMemo(() => { + return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false); + }, [claudeCompatibleProviders]); + + // Build provider options for the dropdown + const providerOptions = useMemo(() => { + const options: Array<{ id: string; name: string; isNative: boolean }> = [ + { id: 'anthropic', name: 'Anthropic Direct', isNative: true }, + ]; + + enabledProviders.forEach((provider) => { + options.push({ + id: provider.id, + name: provider.name, + isNative: false, + }); + }); + + return options; + }, [enabledProviders]); + + // Get the selected provider config (if custom) + const selectedProviderConfig = useMemo(() => { + if (selectedProvider === 'anthropic') return null; + return enabledProviders.find((p) => p.id === selectedProvider); + }, [selectedProvider, enabledProviders]); + + // Get the Claude model alias from a PhaseModelEntry + const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => { + // Check if model string directly matches a Claude alias + if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku'; + if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet'; + if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus'; + + // If it's a provider model, look up the mapping + if (entry.providerId) { + const provider = enabledProviders.find((p) => p.id === entry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === entry.model); + if (model?.mapsToClaudeModel) { + return model.mapsToClaudeModel; + } + } + } + + // Default to sonnet + return 'sonnet'; + }; + + // Find the model from provider that maps to a specific Claude model + const findModelForClaudeAlias = ( + provider: ClaudeCompatibleProvider | null, + claudeAlias: ClaudeModelAlias, + phase: PhaseModelKey + ): PhaseModelEntry => { + if (!provider) { + // Anthropic Direct - reset to default phase model (includes correct thinking levels) + return DEFAULT_PHASE_MODELS[phase]; + } + + // Find model that maps to this Claude alias + const models = provider.models || []; + const match = models.find((m) => m.mapsToClaudeModel === claudeAlias); + + if (match) { + return { providerId: provider.id, model: match.id }; + } + + // Fallback: use first model if no match + if (models.length > 0) { + return { providerId: provider.id, model: models[0].id }; + } + + // Ultimate fallback to native Claude model + return { model: claudeAlias }; + }; + + // Generate preview of changes + const preview = useMemo(() => { + return ALL_PHASES.map((phase) => { + const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]; + const claudeAlias = getClaudeModelAlias(currentEntry); + const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase); + + // Get display names + const getCurrentDisplay = (): string => { + if (currentEntry.providerId) { + const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === currentEntry.model); + return model?.displayName || currentEntry.model; + } + } + return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; + }; + + const getNewDisplay = (): string => { + if (newEntry.providerId && selectedProviderConfig) { + const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); + return model?.displayName || newEntry.model; + } + return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; + }; + + const isChanged = + currentEntry.model !== newEntry.model || + currentEntry.providerId !== newEntry.providerId || + currentEntry.thinkingLevel !== newEntry.thinkingLevel; + + return { + phase, + label: PHASE_LABELS[phase], + claudeAlias, + currentDisplay: getCurrentDisplay(), + newDisplay: getNewDisplay(), + newEntry, + isChanged, + }; + }); + }, [phaseModels, selectedProviderConfig, enabledProviders]); + + // Count how many will change + const changeCount = preview.filter((p) => p.isChanged).length; + + // Apply the bulk replace + const handleApply = () => { + preview.forEach(({ phase, newEntry, isChanged }) => { + if (isChanged) { + setPhaseModel(phase, newEntry); + } + }); + onOpenChange(false); + }; + + // Check if provider has all 3 Claude model mappings + const providerModelCoverage = useMemo(() => { + if (selectedProvider === 'anthropic') { + return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true }; + } + if (!selectedProviderConfig) { + return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false }; + } + const models = selectedProviderConfig.models || []; + const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku'); + const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet'); + const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus'); + return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus }; + }, [selectedProvider, selectedProviderConfig]); + + const providerHasModels = + selectedProvider === 'anthropic' || + (selectedProviderConfig && selectedProviderConfig.models?.length > 0); + + return ( + + + + Bulk Replace Models + + Switch all phase models to equivalents from a specific provider. Models are matched by + their Claude model mapping (Haiku, Sonnet, Opus). + + + +
+ {/* Provider selector */} +
+ + +
+ + {/* Warning if provider has no models */} + {!providerHasModels && ( +
+
+ + This provider has no models configured. +
+
+ )} + + {/* Warning if provider doesn't have all 3 mappings */} + {providerHasModels && !providerModelCoverage.complete && ( +
+
+ + + This provider is missing mappings for:{' '} + {[ + !providerModelCoverage.hasHaiku && 'Haiku', + !providerModelCoverage.hasSonnet && 'Sonnet', + !providerModelCoverage.hasOpus && 'Opus', + ] + .filter(Boolean) + .join(', ')} + +
+
+ )} + + {/* Preview of changes */} + {providerHasModels && ( +
+
+ + + {changeCount} of {ALL_PHASES.length} will change + +
+
+ + + + + + + + + + + {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + + + + + + + ))} + +
PhaseCurrentNew
{label}{currentDisplay} + {isChanged ? ( + + ) : ( + + )} + + + {newDisplay} + +
+
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx index 37f3e72d5..e12000fbf 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx @@ -1,8 +1,10 @@ -import { Workflow, RotateCcw } from 'lucide-react'; +import { useState } from 'react'; +import { Workflow, RotateCcw, Replace } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { PhaseModelSelector } from './phase-model-selector'; +import { BulkReplaceDialog } from './bulk-replace-dialog'; import type { PhaseModelKey } from '@automaker/types'; import { DEFAULT_PHASE_MODELS } from '@automaker/types'; @@ -112,7 +114,12 @@ function PhaseGroup({ } export function ModelDefaultsSection() { - const { resetPhaseModels } = useAppStore(); + const { resetPhaseModels, claudeCompatibleProviders } = useAppStore(); + const [showBulkReplace, setShowBulkReplace] = useState(false); + + // Check if there are any enabled ClaudeCompatibleProviders + const hasEnabledProviders = + claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false); return (
- +
+ {hasEnabledProviders && ( + + )} + +
+ {/* Bulk Replace Dialog */} + + {/* Content */}
{/* Quick Tasks */} diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 69392afac..0a7fcd707 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -9,6 +9,9 @@ import type { OpencodeModelId, GroupedModel, PhaseModelEntry, + ClaudeCompatibleProvider, + ProviderModel, + ClaudeModelAlias, } from '@automaker/types'; import { stripProviderPrefix, @@ -33,6 +36,9 @@ import { AnthropicIcon, CursorIcon, OpenAIIcon, + OpenRouterIcon, + GlmIcon, + MiniMaxIcon, getProviderIconForModel, } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; @@ -154,10 +160,12 @@ export function PhaseModelSelector({ const [expandedGroup, setExpandedGroup] = useState(null); const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); const [expandedCodexModel, setExpandedCodexModel] = useState(null); + const [expandedProviderModel, setExpandedProviderModel] = useState(null); // Format: providerId:modelId const commandListRef = useRef(null); const expandedTriggerRef = useRef(null); const expandedClaudeTriggerRef = useRef(null); const expandedCodexTriggerRef = useRef(null); + const expandedProviderTriggerRef = useRef(null); const { enabledCursorModels, favoriteModels, @@ -170,16 +178,23 @@ export function PhaseModelSelector({ opencodeModelsLoading, fetchOpencodeModels, disabledProviders, + claudeCompatibleProviders, } = useAppStore(); // Detect mobile devices to use inline expansion instead of nested popovers const isMobile = useIsMobile(); - // Extract model and thinking/reasoning levels from value + // Extract model, provider, and thinking/reasoning levels from value const selectedModel = value.model; + const selectedProviderId = value.providerId; const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedReasoningEffort = value.reasoningEffort || 'none'; + // Get enabled providers and their models + const enabledProviders = useMemo(() => { + return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false); + }, [claudeCompatibleProviders]); + // Fetch Codex models on mount useEffect(() => { if (codexModels.length === 0 && !codexModelsLoading) { @@ -267,6 +282,29 @@ export function PhaseModelSelector({ return () => observer.disconnect(); }, [expandedCodexModel]); + // Close expanded provider model popover when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedProviderTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedProviderModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedProviderModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedProviderModel]); + // Transform dynamic Codex models from store to component format const transformedCodexModels = useMemo(() => { return codexModels.map((model) => ({ @@ -337,13 +375,55 @@ export function PhaseModelSelector({ }; } + // Check ClaudeCompatibleProvider models (when providerId is set) + if (selectedProviderId) { + const provider = enabledProviders.find((p) => p.id === selectedProviderId); + if (provider) { + const providerModel = provider.models?.find((m) => m.id === selectedModel); + if (providerModel) { + // Count providers of same type to determine if we need provider name suffix + const sameTypeCount = enabledProviders.filter( + (p) => p.providerType === provider.providerType + ).length; + const suffix = sameTypeCount > 1 ? ` (${provider.name})` : ''; + // Add thinking level to label if not 'none' + const thinkingLabel = + selectedThinkingLevel !== 'none' + ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` + : ''; + // Get icon based on provider type + const getIconForProviderType = () => { + switch (provider.providerType) { + case 'glm': + return GlmIcon; + case 'minimax': + return MiniMaxIcon; + case 'openrouter': + return OpenRouterIcon; + default: + return getProviderIconForModel(providerModel.id) || OpenRouterIcon; + } + }; + return { + id: selectedModel, + label: `${providerModel.displayName}${suffix}${thinkingLabel}`, + description: provider.name, + provider: 'claude-compatible' as const, + icon: getIconForProviderType(), + }; + } + } + } + return null; }, [ selectedModel, + selectedProviderId, selectedThinkingLevel, availableCursorModels, transformedCodexModels, dynamicOpencodeModels, + enabledProviders, ]); // Compute grouped vs standalone Cursor models @@ -907,6 +987,245 @@ export function PhaseModelSelector({ ); }; + // Render ClaudeCompatibleProvider model item with thinking level support + const renderProviderModelItem = ( + provider: ClaudeCompatibleProvider, + model: ProviderModel, + showProviderSuffix: boolean, + allMappedModels: ClaudeModelAlias[] = [] + ) => { + const isSelected = selectedModel === model.id && selectedProviderId === provider.id; + const expandKey = `${provider.id}:${model.id}`; + const isExpanded = expandedProviderModel === expandKey; + const currentThinking = isSelected ? selectedThinkingLevel : 'none'; + const displayName = showProviderSuffix + ? `${model.displayName} (${provider.name})` + : model.displayName; + + // Build description showing all mapped Claude models + const modelLabelMap: Record = { + haiku: 'Haiku', + sonnet: 'Sonnet', + opus: 'Opus', + }; + // Sort in order: haiku, sonnet, opus for consistent display + const sortOrder: ClaudeModelAlias[] = ['haiku', 'sonnet', 'opus']; + const sortedMappedModels = [...allMappedModels].sort( + (a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b) + ); + const mappedModelLabel = + sortedMappedModels.length > 0 + ? sortedMappedModels.map((m) => modelLabelMap[m]).join(', ') + : 'Claude'; + + // Get icon based on provider type, falling back to model-based detection + const getProviderTypeIcon = () => { + switch (provider.providerType) { + case 'glm': + return GlmIcon; + case 'minimax': + return MiniMaxIcon; + case 'openrouter': + return OpenRouterIcon; + default: + // For generic/unknown providers, use OpenRouter as a generic "cloud API" icon + // unless the model ID has a recognizable pattern + return getProviderIconForModel(model.id) || OpenRouterIcon; + } + }; + const ProviderIcon = getProviderTypeIcon(); + + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedProviderModel(isExpanded ? null : expandKey)} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {displayName} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : `Maps to ${mappedModelLabel}`} + +
+
+ +
+ {isSelected && !isExpanded && } + +
+
+ + {/* Inline thinking level options on mobile */} + {isExpanded && ( +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedProviderModel(isExpanded ? null : expandKey)} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedProviderModel(null); + } + }} + > + +
+
+ +
+ + {displayName} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : `Maps to ${mappedModelLabel}`} + +
+
+ +
+ {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+
+
+
+ ); + }; + // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { // With canonical IDs, store the full prefixed ID @@ -1499,6 +1818,50 @@ export function PhaseModelSelector({ )} + {/* ClaudeCompatibleProvider Models - each provider as separate group */} + {enabledProviders.map((provider) => { + if (!provider.models || provider.models.length === 0) return null; + + // Check if we need provider suffix (multiple providers of same type) + const sameTypeCount = enabledProviders.filter( + (p) => p.providerType === provider.providerType + ).length; + const showSuffix = sameTypeCount > 1; + + // Group models by ID and collect all mapped Claude models for each + const modelsByIdMap = new Map< + string, + { model: ProviderModel; mappedModels: ClaudeModelAlias[] } + >(); + for (const model of provider.models) { + const existing = modelsByIdMap.get(model.id); + if (existing) { + // Add this mapped model if not already present + if ( + model.mapsToClaudeModel && + !existing.mappedModels.includes(model.mapsToClaudeModel) + ) { + existing.mappedModels.push(model.mapsToClaudeModel); + } + } else { + // First occurrence of this model ID + modelsByIdMap.set(model.id, { + model, + mappedModels: model.mapsToClaudeModel ? [model.mapsToClaudeModel] : [], + }); + } + } + const uniqueModelsWithMappings = Array.from(modelsByIdMap.values()); + + return ( + + {uniqueModelsWithMappings.map(({ model, mappedModels }) => + renderProviderModelItem(provider, model, showSuffix, mappedModels) + )} + + ); + })} + {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( {/* Grouped models with secondary popover */} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx index 4d69c07df..57b432d08 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx @@ -47,7 +47,7 @@ export function ClaudeSettingsTab() { onRefresh={handleRefreshClaudeCli} /> - {/* API Profiles for Claude-compatible endpoints */} + {/* Claude-compatible providers */} = { + anthropic: 'Anthropic', + glm: 'GLM', + minimax: 'MiniMax', + openrouter: 'OpenRouter', + custom: 'Custom', +}; + +// Provider type badge colors +const PROVIDER_TYPE_COLORS: Record = { + anthropic: 'bg-brand-500/20 text-brand-500', + glm: 'bg-emerald-500/20 text-emerald-500', + minimax: 'bg-purple-500/20 text-purple-500', + openrouter: 'bg-amber-500/20 text-amber-500', + custom: 'bg-zinc-500/20 text-zinc-400', +}; + +// Claude model display names +const CLAUDE_MODEL_LABELS: Record = { + haiku: 'Claude Haiku', + sonnet: 'Claude Sonnet', + opus: 'Claude Opus', +}; + +interface ModelFormEntry { + id: string; + displayName: string; + mapsToClaudeModel: ClaudeModelAlias; +} + +interface ProviderFormData { name: string; + providerType: ClaudeCompatibleProviderType; baseUrl: string; apiKeySource: ApiKeySource; apiKey: string; useAuthToken: boolean; timeoutMs: string; // String for input, convert to number - modelMappings: { - haiku: string; - sonnet: string; - opus: string; - }; + models: ModelFormEntry[]; disableNonessentialTraffic: boolean; } -const emptyFormData: ProfileFormData = { +const emptyFormData: ProviderFormData = { name: '', + providerType: 'custom', baseUrl: '', apiKeySource: 'inline', apiKey: '', useAuthToken: false, timeoutMs: '', - modelMappings: { - haiku: '', - sonnet: '', - opus: '', - }, + models: [], disableNonessentialTraffic: false, }; +// Provider types that have fixed settings (no need to show toggles) +const FIXED_SETTINGS_PROVIDERS: ClaudeCompatibleProviderType[] = ['glm', 'minimax']; + +// Check if provider type has fixed settings +function hasFixedSettings(providerType: ClaudeCompatibleProviderType): boolean { + return FIXED_SETTINGS_PROVIDERS.includes(providerType); +} + export function ApiProfilesSection() { const { - claudeApiProfiles, - activeClaudeApiProfileId, - addClaudeApiProfile, - updateClaudeApiProfile, - deleteClaudeApiProfile, - setActiveClaudeApiProfile, + claudeCompatibleProviders, + addClaudeCompatibleProvider, + updateClaudeCompatibleProvider, + deleteClaudeCompatibleProvider, + toggleClaudeCompatibleProviderEnabled, } = useAppStore(); const [isDialogOpen, setIsDialogOpen] = useState(false); - const [editingProfileId, setEditingProfileId] = useState(null); - const [formData, setFormData] = useState(emptyFormData); + const [editingProviderId, setEditingProviderId] = useState(null); + const [formData, setFormData] = useState(emptyFormData); const [showApiKey, setShowApiKey] = useState(false); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [currentTemplate, setCurrentTemplate] = useState< - (typeof CLAUDE_API_PROFILE_TEMPLATES)[0] | null + (typeof CLAUDE_PROVIDER_TEMPLATES)[0] | null >(null); + const [showModelMappings, setShowModelMappings] = useState(false); const handleOpenAddDialog = (templateName?: string) => { const template = templateName - ? CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.name === templateName) + ? CLAUDE_PROVIDER_TEMPLATES.find((t) => t.name === templateName) : undefined; if (template) { setFormData({ name: template.name, + providerType: template.providerType, baseUrl: template.baseUrl, apiKeySource: template.defaultApiKeySource ?? 'inline', apiKey: '', useAuthToken: template.useAuthToken, timeoutMs: template.timeoutMs?.toString() ?? '', - modelMappings: { - haiku: template.modelMappings?.haiku ?? '', - sonnet: template.modelMappings?.sonnet ?? '', - opus: template.modelMappings?.opus ?? '', - }, + models: (template.defaultModels || []).map((m) => ({ + id: m.id, + displayName: m.displayName, + mapsToClaudeModel: m.mapsToClaudeModel || 'sonnet', + })), disableNonessentialTraffic: template.disableNonessentialTraffic ?? false, }); setCurrentTemplate(template); @@ -128,87 +170,138 @@ export function ApiProfilesSection() { setCurrentTemplate(null); } - setEditingProfileId(null); + setEditingProviderId(null); setShowApiKey(false); + // For fixed providers, hide model mappings by default (they have sensible defaults) + setShowModelMappings(template ? !hasFixedSettings(template.providerType) : true); setIsDialogOpen(true); }; - const handleOpenEditDialog = (profile: ClaudeApiProfile) => { - // Find matching template by base URL - const template = CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.baseUrl === profile.baseUrl); + const handleOpenEditDialog = (provider: ClaudeCompatibleProvider) => { + // Find matching template by provider type + const template = CLAUDE_PROVIDER_TEMPLATES.find( + (t) => t.providerType === provider.providerType + ); setFormData({ - name: profile.name, - baseUrl: profile.baseUrl, - apiKeySource: profile.apiKeySource ?? 'inline', - apiKey: profile.apiKey ?? '', - useAuthToken: profile.useAuthToken ?? false, - timeoutMs: profile.timeoutMs?.toString() ?? '', - modelMappings: { - haiku: profile.modelMappings?.haiku ?? '', - sonnet: profile.modelMappings?.sonnet ?? '', - opus: profile.modelMappings?.opus ?? '', - }, - disableNonessentialTraffic: profile.disableNonessentialTraffic ?? false, + name: provider.name, + providerType: provider.providerType, + baseUrl: provider.baseUrl, + apiKeySource: provider.apiKeySource ?? 'inline', + apiKey: provider.apiKey ?? '', + useAuthToken: provider.useAuthToken ?? false, + timeoutMs: provider.timeoutMs?.toString() ?? '', + models: (provider.models || []).map((m) => ({ + id: m.id, + displayName: m.displayName, + mapsToClaudeModel: m.mapsToClaudeModel || 'sonnet', + })), + disableNonessentialTraffic: provider.disableNonessentialTraffic ?? false, }); - setEditingProfileId(profile.id); + setEditingProviderId(provider.id); setCurrentTemplate(template ?? null); setShowApiKey(false); + // For fixed providers, hide model mappings by default when editing + setShowModelMappings(!hasFixedSettings(provider.providerType)); setIsDialogOpen(true); }; const handleSave = () => { - const profileData: ClaudeApiProfile = { - id: editingProfileId ?? generateProfileId(), + // For GLM/MiniMax, enforce fixed settings + const isFixedProvider = hasFixedSettings(formData.providerType); + + // Convert form models to ProviderModel format + const models: ProviderModel[] = formData.models + .filter((m) => m.id.trim()) // Only include models with IDs + .map((m) => ({ + id: m.id.trim(), + displayName: m.displayName.trim() || m.id.trim(), + mapsToClaudeModel: m.mapsToClaudeModel, + })); + + const providerData: ClaudeCompatibleProvider = { + id: editingProviderId ?? generateProviderId(), name: formData.name.trim(), + providerType: formData.providerType, + enabled: true, baseUrl: formData.baseUrl.trim(), - apiKeySource: formData.apiKeySource, + // For fixed providers, always use inline + apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource, // Only include apiKey when source is 'inline' - apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined, - useAuthToken: formData.useAuthToken, + apiKey: isFixedProvider || formData.apiKeySource === 'inline' ? formData.apiKey : undefined, + // For fixed providers, always use auth token + useAuthToken: isFixedProvider ? true : formData.useAuthToken, timeoutMs: (() => { const parsed = Number(formData.timeoutMs); return Number.isFinite(parsed) ? parsed : undefined; })(), - modelMappings: - formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus - ? { - ...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }), - ...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }), - ...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }), - } - : undefined, - disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined, + models, + // For fixed providers, always disable non-essential + disableNonessentialTraffic: isFixedProvider + ? true + : formData.disableNonessentialTraffic || undefined, }; - if (editingProfileId) { - updateClaudeApiProfile(editingProfileId, profileData); + if (editingProviderId) { + updateClaudeCompatibleProvider(editingProviderId, providerData); } else { - addClaudeApiProfile(profileData); + addClaudeCompatibleProvider(providerData); } setIsDialogOpen(false); setFormData(emptyFormData); - setEditingProfileId(null); + setEditingProviderId(null); }; const handleDelete = (id: string) => { - deleteClaudeApiProfile(id); + deleteClaudeCompatibleProvider(id); setDeleteConfirmId(null); }; - // Check for duplicate profile name (case-insensitive, excluding current profile when editing) - const isDuplicateName = claudeApiProfiles.some( - (p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProfileId + const handleAddModel = () => { + setFormData({ + ...formData, + models: [...formData.models, { id: '', displayName: '', mapsToClaudeModel: 'sonnet' }], + }); + }; + + const handleUpdateModel = (index: number, updates: Partial) => { + const newModels = [...formData.models]; + newModels[index] = { ...newModels[index], ...updates }; + setFormData({ ...formData, models: newModels }); + }; + + const handleRemoveModel = (index: number) => { + setFormData({ + ...formData, + models: formData.models.filter((_, i) => i !== index), + }); + }; + + // Check for duplicate provider name (case-insensitive, excluding current provider when editing) + const isDuplicateName = claudeCompatibleProviders.some( + (p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProviderId ); - // API key is only required when source is 'inline' + // For fixed providers, API key is always required (inline only) + // For others, only required when source is 'inline' + const isFixedProvider = hasFixedSettings(formData.providerType); const isFormValid = formData.name.trim().length > 0 && formData.baseUrl.trim().length > 0 && - (formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) && + (isFixedProvider + ? formData.apiKey.length > 0 + : formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) && !isDuplicateName; + // Check model coverage + const modelCoverage = { + hasHaiku: formData.models.some((m) => m.mapsToClaudeModel === 'haiku'), + hasSonnet: formData.models.some((m) => m.mapsToClaudeModel === 'sonnet'), + hasOpus: formData.models.some((m) => m.mapsToClaudeModel === 'opus'), + }; + const hasAllMappings = modelCoverage.hasHaiku && modelCoverage.hasSonnet && modelCoverage.hasOpus; + return (
-

API Profiles

-

Manage Claude-compatible API endpoints

+

Model Providers

+

+ Configure providers whose models appear in all model selectors +

handleOpenAddDialog()}> - Custom Profile + Custom Provider - {CLAUDE_API_PROFILE_TEMPLATES.map((template) => ( - handleOpenAddDialog(template.name)} - > - - {template.name} - - ))} + {CLAUDE_PROVIDER_TEMPLATES.filter((t) => t.providerType !== 'anthropic').map( + (template) => ( + handleOpenAddDialog(template.name)} + > + + {template.name} + + ) + )} {/* Content */}
- {/* Active Profile Selector */} -
- - -

- {activeClaudeApiProfileId - ? 'Using custom API endpoint' - : 'Using direct Anthropic API (API key or Claude Max plan)'} -

+ {/* Info Banner */} +
+ Models from enabled providers appear in all model dropdowns throughout the app. You can + select different models from different providers for each phase.
- {/* Profile List */} - {claudeApiProfiles.length === 0 ? ( + {/* Provider List */} + {claudeCompatibleProviders.length === 0 ? (
-

No API profiles configured

+

No model providers configured

- Add a profile to use alternative Claude-compatible endpoints + Add a provider to use alternative Claude-compatible models

) : (
- {claudeApiProfiles.map((profile) => ( - handleOpenEditDialog(profile)} - onDelete={() => setDeleteConfirmId(profile.id)} - onSetActive={() => setActiveClaudeApiProfile(profile.id)} + {claudeCompatibleProviders.map((provider) => ( + handleOpenEditDialog(provider)} + onDelete={() => setDeleteConfirmId(provider.id)} + onToggleEnabled={() => toggleClaudeCompatibleProviderEnabled(provider.id)} /> ))}
@@ -320,129 +388,175 @@ export function ApiProfilesSection() { - {editingProfileId ? 'Edit API Profile' : 'Add API Profile'} + + {editingProviderId ? 'Edit Model Provider' : 'Add Model Provider'} + - Configure a Claude-compatible API endpoint. API keys are stored locally. + {isFixedProvider + ? `Configure ${PROVIDER_TYPE_LABELS[formData.providerType]} endpoint with model mappings to Claude.` + : 'Configure a Claude-compatible API endpoint. Models from this provider will appear in all model selectors.'}
{/* Name */}
- + setFormData({ ...formData, name: e.target.value })} - placeholder="e.g., z.AI GLM" + placeholder="e.g., GLM (Work)" className={isDuplicateName ? 'border-destructive' : ''} /> {isDuplicateName && ( -

A profile with this name already exists

+

A provider with this name already exists

)}
- {/* Base URL */} -
- - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder="https://api.example.com/v1" - /> -
+ {/* Provider Type - only for custom providers */} + {!isFixedProvider && ( +
+ + +
+ )} - {/* API Key Source */} + {/* API Key - always shown first for fixed providers */}
- - - {formData.apiKeySource === 'credentials' && ( -

- Will use the Anthropic key from Settings → API Keys -

- )} - {formData.apiKeySource === 'env' && ( -

- Will use ANTHROPIC_API_KEY environment variable -

+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + )}
- {/* API Key (only shown for inline source) */} - {formData.apiKeySource === 'inline' && ( + {/* Base URL - hidden for fixed providers since it's pre-configured */} + {!isFixedProvider && (
- -
- setFormData({ ...formData, apiKey: e.target.value })} - placeholder="Enter API key" - className="pr-10" - /> - -
- {currentTemplate?.apiKeyUrl && ( - - Get API Key from {currentTemplate.name} - - )} + + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + />
)} - {/* Use Auth Token */} -
-
- -

- Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY -

-
- setFormData({ ...formData, useAuthToken: checked })} - /> -
+ {/* Advanced options for non-fixed providers only */} + {!isFixedProvider && ( + <> + {/* API Key Source */} +
+ + +
+ + {/* Use Auth Token */} +
+
+ +

+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY +

+
+ + setFormData({ ...formData, useAuthToken: checked }) + } + /> +
+ + {/* Disable Non-essential Traffic */} +
+
+ +

+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +

+
+ + setFormData({ ...formData, disableNonessentialTraffic: checked }) + } + /> +
+ + )} {/* Timeout */}
- + setFormData({ ...formData, timeoutMs: e.target.value })} @@ -450,84 +564,216 @@ export function ApiProfilesSection() { />
- {/* Model Mappings */} + {/* Models */}
- -

- Map Claude model aliases to provider-specific model names -

-
-
- - - setFormData({ - ...formData, - modelMappings: { ...formData.modelMappings, haiku: e.target.value }, - }) - } - placeholder="e.g., GLM-4.5-Flash" - className="text-xs" - /> -
-
- - - setFormData({ - ...formData, - modelMappings: { ...formData.modelMappings, sonnet: e.target.value }, - }) - } - placeholder="e.g., glm-4.7" - className="text-xs" - /> -
-
- - - setFormData({ - ...formData, - modelMappings: { ...formData.modelMappings, opus: e.target.value }, - }) - } - placeholder="e.g., glm-4.7" - className="text-xs" - /> -
-
-
+ {/* For fixed providers, show collapsible section */} + {isFixedProvider ? ( + <> +
+
+ +

+ {formData.models.length} mappings configured (Haiku, Sonnet, Opus) +

+
+ +
- {/* Disable Non-essential Traffic */} -
-
- -

- Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -

-
- - setFormData({ ...formData, disableNonessentialTraffic: checked }) - } - /> + {/* Expanded model mappings for fixed providers */} + {showModelMappings && ( +
+ {formData.models.map((model, index) => ( +
+
+
+
+ + handleUpdateModel(index, { id: e.target.value })} + placeholder="e.g., GLM-4.7" + className="text-xs h-8" + /> +
+
+ + + handleUpdateModel(index, { displayName: e.target.value }) + } + placeholder="e.g., GLM 4.7" + className="text-xs h-8" + /> +
+
+
+ + +
+
+ +
+ ))} + +
+ )} + + ) : ( + <> + {/* Non-fixed providers: always show full editing UI */} +
+
+ +

+ Map provider models to Claude equivalents (Haiku, Sonnet, Opus) +

+
+ +
+ + {/* Coverage warning - only for non-fixed providers */} + {formData.models.length > 0 && !hasAllMappings && ( +
+ Missing mappings:{' '} + {[ + !modelCoverage.hasHaiku && 'Haiku', + !modelCoverage.hasSonnet && 'Sonnet', + !modelCoverage.hasOpus && 'Opus', + ] + .filter(Boolean) + .join(', ')} +
+ )} + + {formData.models.length === 0 ? ( +
+ No models configured. Add models to use with this provider. +
+ ) : ( +
+ {formData.models.map((model, index) => ( +
+
+
+
+ + handleUpdateModel(index, { id: e.target.value })} + placeholder="e.g., GLM-4.7" + className="text-xs h-8" + /> +
+
+ + + handleUpdateModel(index, { displayName: e.target.value }) + } + placeholder="e.g., GLM 4.7" + className="text-xs h-8" + /> +
+
+
+ + +
+
+ +
+ ))} +
+ )} + + )}
@@ -536,7 +782,7 @@ export function ApiProfilesSection() { Cancel
@@ -546,10 +792,10 @@ export function ApiProfilesSection() { !open && setDeleteConfirmId(null)}> - Delete Profile? + Delete Provider? - This will permanently delete the API profile. If this profile is currently active, you - will be switched to direct Anthropic API. + This will permanently delete the provider and its models. Any phase model + configurations using these models will need to be updated. @@ -569,69 +815,91 @@ export function ApiProfilesSection() { ); } -interface ProfileCardProps { - profile: ClaudeApiProfile; - isActive: boolean; +interface ProviderCardProps { + provider: ClaudeCompatibleProvider; onEdit: () => void; onDelete: () => void; - onSetActive: () => void; + onToggleEnabled: () => void; } -function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) { +function ProviderCard({ provider, onEdit, onDelete, onToggleEnabled }: ProviderCardProps) { + const isEnabled = provider.enabled !== false; + return (
-
-

{profile.name}

- {isActive && ( - - Active - +
+

{provider.name}

+ + {PROVIDER_TYPE_LABELS[provider.providerType]} + + {!isEnabled && ( + + Disabled + )}
-

{profile.baseUrl}

+

{provider.baseUrl}

- Key: {maskApiKey(profile.apiKey)} - {profile.useAuthToken && Auth Token} - {profile.timeoutMs && Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s} + Key: {maskApiKey(provider.apiKey)} + {provider.models?.length || 0} model(s)
+ {/* Show models with their Claude mapping */} + {provider.models && provider.models.length > 0 && ( +
+ {provider.models.map((model) => ( + + {model.displayName || model.id} + {model.mapsToClaudeModel && ( + + → {CLAUDE_MODEL_LABELS[model.mapsToClaudeModel]} + + )} + + ))} +
+ )}
- - - - - - {!isActive && ( - - - Set Active +
+ + + + + + + + + Edit - )} - - - Edit - - - - - Delete - - - + + + + Delete + + + +
); diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index a4531d221..e672d411e 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -95,18 +95,45 @@ export function useProjectSettingsLoader() { setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator); } - // Apply activeClaudeApiProfileId if present - if (settings.activeClaudeApiProfileId !== undefined) { - const updatedProject = useAppStore.getState().currentProject; - if ( - updatedProject && - updatedProject.path === projectPath && - updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId - ) { - setCurrentProject({ + // Apply activeClaudeApiProfileId and phaseModelOverrides if present + // These are stored directly on the project, so we need to update both + // currentProject AND the projects array to keep them in sync + // Type assertion needed because API returns Record + const settingsWithExtras = settings as Record; + const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as + | string + | null + | undefined; + const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as + | import('@automaker/types').PhaseModelConfig + | undefined; + + // Check if we need to update the project + const storeState = useAppStore.getState(); + const updatedProject = storeState.currentProject; + if (updatedProject && updatedProject.path === projectPath) { + const needsUpdate = + (activeClaudeApiProfileId !== undefined && + updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) || + (phaseModelOverrides !== undefined && + JSON.stringify(updatedProject.phaseModelOverrides) !== + JSON.stringify(phaseModelOverrides)); + + if (needsUpdate) { + const updatedProjectData = { ...updatedProject, - activeClaudeApiProfileId: settings.activeClaudeApiProfileId, - }); + ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }), + ...(phaseModelOverrides !== undefined && { phaseModelOverrides }), + }; + + // Update currentProject + setCurrentProject(updatedProjectData); + + // Also update the project in the projects array to keep them in sync + const updatedProjects = storeState.projects.map((p) => + p.id === updatedProject.id ? updatedProjectData : p + ); + useAppStore.setState({ projects: updatedProjects }); } } }, [ diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index def64ef00..ff24d42ff 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -720,6 +720,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { mcpServers: settings.mcpServers ?? [], promptCustomization: settings.promptCustomization ?? {}, eventHooks: settings.eventHooks ?? [], + claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [], claudeApiProfiles: settings.claudeApiProfiles ?? [], activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null, projects, @@ -798,6 +799,7 @@ function buildSettingsUpdateFromStore(): Record { mcpServers: state.mcpServers, promptCustomization: state.promptCustomization, eventHooks: state.eventHooks, + claudeCompatibleProviders: state.claudeCompatibleProviders, claudeApiProfiles: state.claudeApiProfiles, activeClaudeApiProfileId: state.activeClaudeApiProfileId, projects: state.projects, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index b0da8596d..4f3110254 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -3403,8 +3403,15 @@ export interface Project { * - undefined: Use global setting (activeClaudeApiProfileId) * - null: Explicitly use Direct Anthropic API (no profile) * - string: Use specific profile by ID + * @deprecated Use phaseModelOverrides instead for per-phase model selection */ activeClaudeApiProfileId?: string | null; + /** + * Per-phase model overrides for this project. + * Keys are phase names (e.g., 'enhancementModel'), values are PhaseModelEntry. + * If a phase is not present, the global setting is used. + */ + phaseModelOverrides?: Partial; } export interface TrashedProject extends Project { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 5f4eadff5..63dd79601 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -33,6 +33,7 @@ import type { ServerLogLevel, EventHook, ClaudeApiProfile, + ClaudeCompatibleProvider, } from '@automaker/types'; import { getAllCursorModelIds, @@ -752,7 +753,10 @@ export interface AppState { // Event Hooks eventHooks: EventHook[]; // Event hooks for custom commands or webhooks - // Claude API Profiles + // Claude-Compatible Providers (new system) + claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns + + // Claude API Profiles (deprecated - kept for backward compatibility) claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API) @@ -1040,8 +1044,17 @@ export interface AppActions { getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default) // Claude API Profile actions (per-project override) + /** @deprecated Use setProjectPhaseModelOverride instead */ setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile) + // Project Phase Model Overrides + setProjectPhaseModelOverride: ( + projectId: string, + phase: import('@automaker/types').PhaseModelKey, + entry: import('@automaker/types').PhaseModelEntry | null // null = use global + ) => void; + clearAllProjectPhaseModelOverrides: (projectId: string) => void; + // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; @@ -1211,7 +1224,17 @@ export interface AppActions { // Event Hook actions setEventHooks: (hooks: EventHook[]) => void; - // Claude API Profile actions + // Claude-Compatible Provider actions (new system) + addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise; + updateClaudeCompatibleProvider: ( + id: string, + updates: Partial + ) => Promise; + deleteClaudeCompatibleProvider: (id: string) => Promise; + setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise; + toggleClaudeCompatibleProviderEnabled: (id: string) => Promise; + + // Claude API Profile actions (deprecated - kept for backward compatibility) addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise; updateClaudeApiProfile: (id: string, updates: Partial) => Promise; deleteClaudeApiProfile: (id: string) => Promise; @@ -1476,8 +1499,9 @@ const initialState: AppState = { subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults eventHooks: [], // No event hooks configured by default - claudeApiProfiles: [], // No Claude API profiles configured by default - activeClaudeApiProfileId: null, // Use direct Anthropic API by default + claudeCompatibleProviders: [], // Claude-compatible providers that expose models + claudeApiProfiles: [], // No Claude API profiles configured by default (deprecated) + activeClaudeApiProfileId: null, // Use direct Anthropic API by default (deprecated) projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, @@ -2017,6 +2041,98 @@ export const useAppStore = create()((set, get) => ({ }); }, + // Project Phase Model Override actions + setProjectPhaseModelOverride: (projectId, phase, entry) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot set phase model override: project not found'); + return; + } + + // Get current overrides or start fresh + const currentOverrides = project.phaseModelOverrides || {}; + + // Build new overrides + let newOverrides: typeof currentOverrides; + if (entry === null) { + // Remove the override (use global) + const { [phase]: _, ...rest } = currentOverrides; + newOverrides = rest; + } else { + // Set the override + newOverrides = { ...currentOverrides, [phase]: entry }; + } + + // Update the project's phaseModelOverrides + const projects = get().projects.map((p) => + p.id === projectId + ? { + ...p, + phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined, + } + : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined, + }, + }); + } + + // Persist to server + const httpClient = getHttpApiClient(); + httpClient.settings + .updateProject(project.path, { + phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : '__CLEAR__', + }) + .catch((error) => { + console.error('Failed to persist phaseModelOverrides:', error); + }); + }, + + clearAllProjectPhaseModelOverrides: (projectId) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot clear phase model overrides: project not found'); + return; + } + + // Clear overrides from project + const projects = get().projects.map((p) => + p.id === projectId ? { ...p, phaseModelOverrides: undefined } : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + phaseModelOverrides: undefined, + }, + }); + } + + // Persist to server + const httpClient = getHttpApiClient(); + httpClient.settings + .updateProject(project.path, { + phaseModelOverrides: '__CLEAR__', + }) + .catch((error) => { + console.error('Failed to clear phaseModelOverrides:', error); + }); + }, + // Feature actions setFeatures: (features) => set({ features }), @@ -2601,7 +2717,53 @@ export const useAppStore = create()((set, get) => ({ // Event Hook actions setEventHooks: (hooks) => set({ eventHooks: hooks }), - // Claude API Profile actions + // Claude-Compatible Provider actions (new system) + addClaudeCompatibleProvider: async (provider) => { + set({ claudeCompatibleProviders: [...get().claudeCompatibleProviders, provider] }); + // Sync immediately to persist provider + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + updateClaudeCompatibleProvider: async (id, updates) => { + set({ + claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) => + p.id === id ? { ...p, ...updates } : p + ), + }); + // Sync immediately to persist changes + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + deleteClaudeCompatibleProvider: async (id) => { + set({ + claudeCompatibleProviders: get().claudeCompatibleProviders.filter((p) => p.id !== id), + }); + // Sync immediately to persist deletion + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setClaudeCompatibleProviders: async (providers) => { + set({ claudeCompatibleProviders: providers }); + // Sync immediately to persist providers + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + toggleClaudeCompatibleProviderEnabled: async (id) => { + set({ + claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) => + p.id === id ? { ...p, enabled: p.enabled === false ? true : false } : p + ), + }); + // Sync immediately to persist change + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + // Claude API Profile actions (deprecated - kept for backward compatibility) addClaudeApiProfile: async (profile) => { set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] }); // Sync immediately to persist profile diff --git a/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md index 4bb8e9360..3463b9fb6 100644 --- a/docs/UNIFIED_API_KEY_PROFILES.md +++ b/docs/UNIFIED_API_KEY_PROFILES.md @@ -1,204 +1,114 @@ -# Unified Claude API Key and Profile System +# Claude Compatible Providers System -This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved. +This document describes the implementation of Claude Compatible Providers, allowing users to configure alternative API endpoints that expose Claude-compatible models to the application. -## Problem Statement +## Overview -Previously, Automaker had two separate systems for configuring Claude API access: +Claude Compatible Providers allow Automaker to work with third-party API endpoints that implement Claude's API protocol. This enables: -1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active -2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys +- **Cost savings**: Use providers like z.AI GLM or MiniMax at lower costs +- **Alternative models**: Access models like GLM-4.7 or MiniMax M2.1 through familiar interfaces +- **Flexibility**: Configure per-phase model selection to optimize for speed vs quality +- **Project overrides**: Use different providers for different projects -This created several issues: +## Architecture -- Users configured Anthropic key in one place, but alternative endpoints in another -- No way to create a "Direct Anthropic" profile that reused the stored credentials -- Environment variable detection didn't integrate with the profile system -- Duplicated API key entry when users wanted the same key for multiple configurations +### Type Definitions -## Solution Overview - -The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from: - -| Source | Description | -| ------------- | ----------------------------------------------------------------- | -| `inline` | API key stored directly in the profile (legacy behavior, default) | -| `env` | Uses `ANTHROPIC_API_KEY` environment variable | -| `credentials` | Uses the Anthropic key from Settings → API Keys | - -This allows: - -- A single API key to be shared across multiple profile configurations -- "Direct Anthropic" profile that references saved credentials -- Environment variable support for CI/CD and containerized deployments -- Backwards compatibility with existing inline key profiles - -## Implementation Details - -### Type Changes - -#### New Type: `ApiKeySource` - -```typescript -// libs/types/src/settings.ts -export type ApiKeySource = 'inline' | 'env' | 'credentials'; -``` - -#### Updated Interface: `ClaudeApiProfile` +#### ClaudeCompatibleProvider ```typescript -export interface ClaudeApiProfile { - id: string; - name: string; - baseUrl: string; - - // NEW: API key sourcing strategy (default: 'inline' for backwards compat) - apiKeySource?: ApiKeySource; - - // Now optional - only required when apiKeySource = 'inline' - apiKey?: string; - - // Existing fields unchanged... - useAuthToken?: boolean; - timeoutMs?: number; - modelMappings?: { haiku?: string; sonnet?: string; opus?: string }; - disableNonessentialTraffic?: boolean; +export interface ClaudeCompatibleProvider { + id: string; // Unique identifier (UUID) + name: string; // Display name (e.g., "z.AI GLM") + baseUrl: string; // API endpoint URL + providerType?: string; // Provider type for icon/grouping (e.g., 'glm', 'minimax', 'openrouter') + apiKeySource?: ApiKeySource; // 'inline' | 'env' | 'credentials' + apiKey?: string; // API key (when apiKeySource = 'inline') + useAuthToken?: boolean; // Use ANTHROPIC_AUTH_TOKEN header + timeoutMs?: number; // Request timeout in milliseconds + disableNonessentialTraffic?: boolean; // Minimize non-essential API calls + enabled?: boolean; // Whether provider is active (default: true) + models?: ProviderModel[]; // Models exposed by this provider } ``` -#### Updated Interface: `ClaudeApiProfileTemplate` +#### ProviderModel ```typescript -export interface ClaudeApiProfileTemplate { - name: string; - baseUrl: string; - defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template - useAuthToken: boolean; - // ... other fields +export interface ProviderModel { + id: string; // Model ID sent to API (e.g., "GLM-4.7") + displayName: string; // Display name in UI (e.g., "GLM 4.7") + mapsToClaudeModel?: ClaudeModelAlias; // Which Claude tier this replaces ('haiku' | 'sonnet' | 'opus') + capabilities?: { + supportsVision?: boolean; // Whether model supports image inputs + supportsThinking?: boolean; // Whether model supports extended thinking + maxThinkingLevel?: ThinkingLevel; // Maximum thinking level if supported + }; } ``` -### Provider Templates +#### PhaseModelEntry -The following provider templates are available: - -#### Direct Anthropic +Phase model configuration now supports provider models: ```typescript -{ - name: 'Direct Anthropic', - baseUrl: 'https://api.anthropic.com', - defaultApiKeySource: 'credentials', - useAuthToken: false, - description: 'Standard Anthropic API with your API key', - apiKeyUrl: 'https://console.anthropic.com/settings/keys', +export interface PhaseModelEntry { + providerId?: string; // Provider ID (undefined = native Claude) + model: string; // Model ID or alias + thinkingLevel?: ThinkingLevel; // 'none' | 'low' | 'medium' | 'high' } ``` -#### OpenRouter +### Provider Templates -Access Claude and 300+ other models through OpenRouter's unified API. +Available provider templates in `CLAUDE_PROVIDER_TEMPLATES`: -```typescript -{ - name: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api', - defaultApiKeySource: 'inline', - useAuthToken: true, - description: 'Access Claude and 300+ models via OpenRouter', - apiKeyUrl: 'https://openrouter.ai/keys', -} -``` +| Template | Provider Type | Base URL | Description | +| ---------------- | ------------- | ------------------------------------ | ----------------------------- | +| Direct Anthropic | anthropic | `https://api.anthropic.com` | Standard Anthropic API | +| OpenRouter | openrouter | `https://openrouter.ai/api` | Access Claude and 300+ models | +| z.AI GLM | glm | `https://api.z.ai/api/anthropic` | GLM models at lower cost | +| MiniMax | minimax | `https://api.minimax.io/anthropic` | MiniMax M2.1 model | +| MiniMax (China) | minimax | `https://api.minimaxi.com/anthropic` | MiniMax for China region | -**Notes:** +### Model Mappings -- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key -- No model mappings by default - OpenRouter auto-maps Anthropic models -- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`) +Each provider model specifies which Claude model tier it maps to via `mapsToClaudeModel`: -#### z.AI GLM +**z.AI GLM:** -```typescript -{ - name: 'z.AI GLM', - baseUrl: 'https://api.z.ai/api/anthropic', - defaultApiKeySource: 'inline', - useAuthToken: true, - timeoutMs: 3000000, - modelMappings: { - haiku: 'GLM-4.5-Air', - sonnet: 'GLM-4.7', - opus: 'GLM-4.7', - }, - disableNonessentialTraffic: true, - description: '3× usage at fraction of cost via GLM Coding Plan', - apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', -} -``` +- `GLM-4.5-Air` → haiku +- `GLM-4.7` → sonnet, opus -#### MiniMax +**MiniMax:** -MiniMax M2.1 coding model with extended context support. +- `MiniMax-M2.1` → haiku, sonnet, opus -```typescript -{ - name: 'MiniMax', - baseUrl: 'https://api.minimax.io/anthropic', - defaultApiKeySource: 'inline', - useAuthToken: true, - timeoutMs: 3000000, - modelMappings: { - haiku: 'MiniMax-M2.1', - sonnet: 'MiniMax-M2.1', - opus: 'MiniMax-M2.1', - }, - disableNonessentialTraffic: true, - description: 'MiniMax M2.1 coding model with extended context', - apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', -} -``` - -#### MiniMax (China) +**OpenRouter:** -Same as MiniMax but using the China-region endpoint. - -```typescript -{ - name: 'MiniMax (China)', - baseUrl: 'https://api.minimaxi.com/anthropic', - defaultApiKeySource: 'inline', - useAuthToken: true, - timeoutMs: 3000000, - modelMappings: { - haiku: 'MiniMax-M2.1', - sonnet: 'MiniMax-M2.1', - opus: 'MiniMax-M2.1', - }, - disableNonessentialTraffic: true, - description: 'MiniMax M2.1 for users in China', - apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', -} -``` +- `anthropic/claude-3.5-haiku` → haiku +- `anthropic/claude-3.5-sonnet` → sonnet +- `anthropic/claude-3-opus` → opus -### Server-Side Changes +## Server-Side Implementation -#### 1. Environment Building (`claude-provider.ts`) +### API Key Resolution -The `buildEnv()` function now resolves API keys based on the `apiKeySource`: +The `buildEnv()` function in `claude-provider.ts` resolves API keys based on `apiKeySource`: ```typescript function buildEnv( - profile?: ClaudeApiProfile, - credentials?: Credentials // NEW parameter + providerConfig?: ClaudeCompatibleProvider, + credentials?: Credentials ): Record { - if (profile) { - // Resolve API key based on source strategy + if (providerConfig) { let apiKey: string | undefined; - const source = profile.apiKeySource ?? 'inline'; + const source = providerConfig.apiKeySource ?? 'inline'; switch (source) { case 'inline': - apiKey = profile.apiKey; + apiKey = providerConfig.apiKey; break; case 'env': apiKey = process.env.ANTHROPIC_API_KEY; @@ -207,242 +117,207 @@ function buildEnv( apiKey = credentials?.apiKeys?.anthropic; break; } - - // ... rest of profile-based env building + // ... build environment with resolved key } - // ... no-profile fallback } ``` -#### 2. Settings Helper (`settings-helpers.ts`) +### Provider Lookup -The `getActiveClaudeApiProfile()` function now returns both profile and credentials: +The `getProviderByModelId()` helper resolves provider configuration from model IDs: ```typescript -export interface ActiveClaudeApiProfileResult { - profile: ClaudeApiProfile | undefined; - credentials: Credentials | undefined; -} - -export async function getActiveClaudeApiProfile( - settingsService?: SettingsService | null, - logPrefix = '[SettingsHelper]' -): Promise { - // Returns both profile and credentials for API key resolution -} +export async function getProviderByModelId( + modelId: string, + settingsService: SettingsService, + logPrefix?: string +): Promise<{ + provider?: ClaudeCompatibleProvider; + resolvedModel?: string; + credentials?: Credentials; +}>; ``` -#### 3. Auto-Migration (`settings-service.ts`) - -A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users: - -```typescript -// Migration v4 -> v5: Auto-create "Direct Anthropic" profile -if (storedVersion < 5) { - const credentials = await this.getCredentials(); - const hasAnthropicKey = !!credentials.apiKeys?.anthropic; - const hasNoProfiles = !result.claudeApiProfiles?.length; - const hasNoActiveProfile = !result.activeClaudeApiProfileId; - - if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { - // Create "Direct Anthropic" profile with apiKeySource: 'credentials' - // and set it as active - } -} -``` +This is used by all routes that call the Claude SDK to: -#### 4. Updated Call Sites - -All files that call `getActiveClaudeApiProfile()` were updated to: - -1. Destructure both `profile` and `credentials` from the result -2. Pass `credentials` to the provider via `ExecuteOptions` - -**Files updated:** - -- `apps/server/src/services/agent-service.ts` -- `apps/server/src/services/auto-mode-service.ts` (2 locations) -- `apps/server/src/services/ideation-service.ts` (2 locations) -- `apps/server/src/providers/simple-query-service.ts` -- `apps/server/src/routes/enhance-prompt/routes/enhance.ts` -- `apps/server/src/routes/context/routes/describe-file.ts` -- `apps/server/src/routes/context/routes/describe-image.ts` -- `apps/server/src/routes/github/routes/validate-issue.ts` -- `apps/server/src/routes/worktree/routes/generate-commit-message.ts` -- `apps/server/src/routes/features/routes/generate-title.ts` -- `apps/server/src/routes/backlog-plan/generate-plan.ts` -- `apps/server/src/routes/app-spec/sync-spec.ts` -- `apps/server/src/routes/app-spec/generate-features-from-spec.ts` -- `apps/server/src/routes/app-spec/generate-spec.ts` -- `apps/server/src/routes/suggestions/generate-suggestions.ts` - -### UI Changes - -#### 1. Profile Form (`api-profiles-section.tsx`) - -Added an API Key Source selector dropdown: - -```tsx - -``` +1. Check if the model ID belongs to a provider +2. Get the provider configuration (baseUrl, auth, etc.) +3. Resolve the `mapsToClaudeModel` for the SDK -The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. +### Phase Model Resolution -#### 2. API Keys Section (`api-keys-section.tsx`) +The `getPhaseModelWithOverrides()` helper gets effective phase model config: -Added an informational note: +```typescript +export async function getPhaseModelWithOverrides( + phaseKey: PhaseModelKey, + settingsService: SettingsService, + projectPath?: string, + logPrefix?: string +): Promise<{ + model: string; + thinkingLevel?: ThinkingLevel; + providerId?: string; + providerConfig?: ClaudeCompatibleProvider; + credentials?: Credentials; +}>; +``` -> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it. +This handles: -## User Flows +1. Project-level overrides (if projectPath provided) +2. Global phase model settings +3. Default fallback models -### New User Flow +## UI Implementation -1. Go to Settings → API Keys -2. Enter Anthropic API key and save -3. Go to Settings → Providers → Claude -4. Create new profile from "Direct Anthropic" template -5. API Key Source defaults to "credentials" - no need to re-enter key -6. Save profile and set as active +### Model Selection Dropdowns -### Existing User Migration +Phase model selectors (`PhaseModelSelector`) display: -When an existing user with an Anthropic API key (but no profiles) loads settings: +1. **Claude Models** - Native Claude models (Haiku, Sonnet, Opus) +2. **Provider Sections** - Each enabled provider as a separate group: + - Section header: `{provider.name} (via Claude)` + - Models with their mapped Claude tiers: "Maps to Haiku, Sonnet, Opus" + - Thinking level submenu for models that support it -1. System detects v4→v5 migration needed -2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'` -3. Sets new profile as active -4. User's existing workflow continues to work seamlessly +### Provider Icons -### Environment Variable Flow +Icons are determined by `providerType`: -For CI/CD or containerized deployments: +- `glm` → Z logo +- `minimax` → MiniMax logo +- `openrouter` → OpenRouter logo +- Generic → OpenRouter as fallback -1. Set `ANTHROPIC_API_KEY` in environment -2. Create profile with `apiKeySource: 'env'` -3. Profile will use the environment variable at runtime +### Bulk Replace -## Backwards Compatibility +The "Bulk Replace" feature allows switching all phase models to a provider at once: -- Profiles without `apiKeySource` field default to `'inline'` -- Existing profiles with inline `apiKey` continue to work unchanged -- No changes to the credentials file format -- Settings version bumped from 4 to 5 (migration is additive) +1. Select a provider from the dropdown +2. Preview shows which models will be assigned: + - haiku phases → provider's haiku-mapped model + - sonnet phases → provider's sonnet-mapped model + - opus phases → provider's opus-mapped model +3. Apply replaces all phase model configurations -## Files Changed +The Bulk Replace button only appears when at least one provider is enabled. -| File | Changes | -| --------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | -| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | -| `libs/types/src/index.ts` | Exported `ApiKeySource` type | -| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | -| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | -| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration | -| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | -| `apps/server/src/services/*.ts` | Updated to pass credentials | -| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | -| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | -| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | +## Project-Level Overrides -## Testing +Projects can override global phase model settings via `phaseModelOverrides`: -To verify the implementation: +```typescript +interface Project { + // ... + phaseModelOverrides?: PhaseModelConfig; // Per-phase overrides +} +``` -1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works -2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile -3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works -4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working -5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline` +### Storage -```bash -# Build and run -npm run build:packages -npm run dev:web +Project overrides are stored in `.automaker/settings.json`: -# Run server tests -npm run test:server +```json +{ + "phaseModelOverrides": { + "enhancementModel": { + "providerId": "provider-uuid", + "model": "GLM-4.5-Air", + "thinkingLevel": "none" + } + } +} ``` -## Per-Project Profile Override +### Resolution Priority -Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations. +1. Project override for specific phase (if set) +2. Global phase model setting +3. Default model for phase -### Configuration +## Migration -In **Project Settings → Claude**, users can select: +### v5 → v6 Migration -| Option | Behavior | -| ------------------------ | ------------------------------------------------------------------ | -| **Use Global Setting** | Inherits the active profile from global settings (default) | -| **Direct Anthropic API** | Explicitly uses direct Anthropic API, bypassing any global profile | -| **\** | Uses that specific profile for this project only | +The system migrated from `claudeApiProfiles` to `claudeCompatibleProviders`: -### Storage - -The per-project setting is stored in `.automaker/settings.json`: +```typescript +// Old: modelMappings object +{ + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7' + } +} -```json +// New: models array with mapsToClaudeModel { - "activeClaudeApiProfileId": "profile-id-here" + models: [ + { id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' }, + { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' }, + { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' }, + ] } ``` -- `undefined` (or key absent): Use global setting -- `null`: Explicitly use Direct Anthropic API -- `""`: Use specific profile by ID +The migration is automatic and preserves existing provider configurations. + +## Files Changed -### Implementation +### Types -The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter: +| File | Changes | +| ---------------------------- | -------------------------------------------------------------------- | +| `libs/types/src/settings.ts` | `ClaudeCompatibleProvider`, `ProviderModel`, `PhaseModelEntry` types | +| `libs/types/src/provider.ts` | `ExecuteOptions.claudeCompatibleProvider` field | +| `libs/types/src/index.ts` | Exports for new types | -```typescript -export async function getActiveClaudeApiProfile( - settingsService?: SettingsService | null, - logPrefix = '[SettingsHelper]', - projectPath?: string // Optional: check project settings first -): Promise; -``` +### Server -When `projectPath` is provided: +| File | Changes | +| ---------------------------------------------- | -------------------------------------------------------- | +| `apps/server/src/providers/claude-provider.ts` | Provider config handling, buildEnv updates | +| `apps/server/src/lib/settings-helpers.ts` | `getProviderByModelId()`, `getPhaseModelWithOverrides()` | +| `apps/server/src/services/settings-service.ts` | v5→v6 migration | +| `apps/server/src/routes/**/*.ts` | Provider lookup for all SDK calls | -1. Project settings are checked first for `activeClaudeApiProfileId` -2. If project has a value (including `null`), that takes precedence -3. If project has no override (`undefined`), falls back to global setting +### UI -### Scope +| File | Changes | +| -------------------------------------------------- | ----------------------------------------- | +| `apps/ui/src/.../phase-model-selector.tsx` | Provider model rendering, thinking levels | +| `apps/ui/src/.../bulk-replace-dialog.tsx` | Bulk replace feature | +| `apps/ui/src/.../api-profiles-section.tsx` | Provider management UI | +| `apps/ui/src/components/ui/provider-icon.tsx` | Provider-specific icons | +| `apps/ui/src/hooks/use-project-settings-loader.ts` | Load phaseModelOverrides | -**Important:** Per-project profiles only affect Claude model calls. When other providers are used (Codex, OpenCode, Cursor), the Claude API profile setting has no effect—those providers use their own configuration. +## Testing -Affected operations when using Claude models: +```bash +# Build and run +npm run build:packages +npm run dev:web -- Agent chat and feature implementation -- Code analysis and suggestions -- Commit message generation -- Spec generation and sync -- Issue validation -- Backlog planning +# Run server tests +npm run test:server +``` -### Use Cases +### Test Cases -1. **Experimentation**: Test z.AI GLM or MiniMax on a side project while keeping production projects on Direct Anthropic -2. **Cost optimization**: Use cheaper endpoints for hobby projects, premium for work projects -3. **Regional compliance**: Use China endpoints for projects with data residency requirements +1. **Provider setup**: Add z.AI GLM provider with inline API key +2. **Model selection**: Select GLM-4.7 for a phase, verify it appears in dropdown +3. **Thinking levels**: Select thinking level for provider model +4. **Bulk replace**: Switch all phases to a provider at once +5. **Project override**: Set per-project model override, verify it persists +6. **Provider deletion**: Delete all providers, verify empty state persists ## Future Enhancements -Potential future improvements: +Potential improvements: -1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources -2. **Validation**: Warn if selected source has no key configured -3. **Per-provider credentials**: Support different credential keys for different providers -4. **Key rotation**: Support for rotating keys without updating profiles +1. **Provider validation**: Test API connection before saving +2. **Usage tracking**: Show which phases use which provider +3. **Cost estimation**: Display estimated costs per provider +4. **Model capabilities**: Auto-detect supported features from provider diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index df592d9ec..d486d61b2 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -113,11 +113,12 @@ export function resolveModelString( return canonicalKey; } - // Unknown model key - use default - console.warn( - `[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"` + // Unknown model key - pass through as-is (could be a provider model like GLM-4.7, MiniMax-M2.1) + // This allows ClaudeCompatibleProvider models to work without being registered here + console.log( + `[ModelResolver] Unknown model key "${canonicalKey}", passing through unchanged (may be a provider model)` ); - return defaultModel; + return canonicalKey; } /** @@ -145,6 +146,8 @@ export interface ResolvedPhaseModel { model: string; /** Optional thinking level for extended thinking */ thinkingLevel?: ThinkingLevel; + /** Provider ID if using a ClaudeCompatibleProvider */ + providerId?: string; } /** @@ -198,8 +201,23 @@ export function resolvePhaseModel( // Handle new PhaseModelEntry object format console.log( - `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}"` + `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"` ); + + // If providerId is set, pass through the model string unchanged + // (it's a provider-specific model ID like "GLM-4.5-Air", not a Claude alias) + if (phaseModel.providerId) { + console.log( + `[ModelResolver] Using provider model: providerId="${phaseModel.providerId}", model="${phaseModel.model}"` + ); + return { + model: phaseModel.model, // Pass through unchanged + thinkingLevel: phaseModel.thinkingLevel, + providerId: phaseModel.providerId, + }; + } + + // No providerId - resolve through normal Claude model mapping return { model: resolveModelString(phaseModel.model, defaultModel), thinkingLevel: phaseModel.thinkingLevel, diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 1ea410cc1..a8f2644db 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -161,8 +161,14 @@ export type { EventHookHttpAction, EventHookAction, EventHook, - // Claude API profile types + // Claude-compatible provider types (new) ApiKeySource, + ClaudeCompatibleProviderType, + ClaudeModelAlias, + ProviderModel, + ClaudeCompatibleProvider, + ClaudeCompatibleProviderTemplate, + // Claude API profile types (deprecated) ClaudeApiProfile, ClaudeApiProfileTemplate, } from './settings.js'; @@ -180,7 +186,9 @@ export { getThinkingTokenBudget, // Event hook constants EVENT_HOOK_TRIGGER_LABELS, - // Claude API profile constants + // Claude-compatible provider templates (new) + CLAUDE_PROVIDER_TEMPLATES, + // Claude API profile constants (deprecated) CLAUDE_API_PROFILE_TEMPLATES, } from './settings.js'; diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 6fddb460d..335000486 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,7 +2,12 @@ * Shared types for AI model providers */ -import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js'; +import type { + ThinkingLevel, + ClaudeApiProfile, + ClaudeCompatibleProvider, + Credentials, +} from './settings.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; /** @@ -213,11 +218,19 @@ export interface ExecuteOptions { * Active Claude API profile for alternative endpoint configuration. * When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API. * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth). + * @deprecated Use claudeCompatibleProvider instead */ claudeApiProfile?: ClaudeApiProfile; /** - * Credentials for resolving 'credentials' apiKeySource in Claude API profiles. - * When a profile has apiKeySource='credentials', the Anthropic key from this object is used. + * Claude-compatible provider for alternative endpoint configuration. + * When set, uses provider's connection settings (base URL, auth) instead of direct Anthropic API. + * Models are passed directly without alias mapping. + * Takes precedence over claudeApiProfile if both are set. + */ + claudeCompatibleProvider?: ClaudeCompatibleProvider; + /** + * Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers. + * When a profile/provider has apiKeySource='credentials', the Anthropic key from this object is used. */ credentials?: Credentials; } diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 644dbc3f5..8a10a6f81 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -102,7 +102,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; // ============================================================================ -// Claude API Profiles - Configuration for Claude-compatible API endpoints +// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints // ============================================================================ /** @@ -114,10 +114,90 @@ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; */ export type ApiKeySource = 'inline' | 'env' | 'credentials'; +/** + * ClaudeCompatibleProviderType - Type of Claude-compatible provider + * + * Used to determine provider-specific UI screens and default configurations. + */ +export type ClaudeCompatibleProviderType = + | 'anthropic' // Direct Anthropic API (built-in) + | 'glm' // z.AI GLM + | 'minimax' // MiniMax + | 'openrouter' // OpenRouter proxy + | 'custom'; // User-defined custom provider + +/** + * ClaudeModelAlias - The three main Claude model aliases for mapping + */ +export type ClaudeModelAlias = 'haiku' | 'sonnet' | 'opus'; + +/** + * ProviderModel - A model exposed by a Claude-compatible provider + * + * Each provider configuration can expose multiple models that will appear + * in all model dropdowns throughout the app. Models map directly to a + * Claude model (haiku, sonnet, opus) for bulk replace and display. + */ +export interface ProviderModel { + /** Model ID sent to the API (e.g., "GLM-4.7", "MiniMax-M2.1") */ + id: string; + /** Display name shown in UI (e.g., "GLM 4.7", "MiniMax M2.1") */ + displayName: string; + /** Which Claude model this maps to (for bulk replace and display) */ + mapsToClaudeModel?: ClaudeModelAlias; + /** Model capabilities */ + capabilities?: { + /** Whether model supports vision/image inputs */ + supportsVision?: boolean; + /** Whether model supports extended thinking */ + supportsThinking?: boolean; + /** Maximum thinking level if thinking is supported */ + maxThinkingLevel?: ThinkingLevel; + }; +} + +/** + * ClaudeCompatibleProvider - Configuration for a Claude-compatible API endpoint + * + * Providers expose their models to all model dropdowns in the app. + * Each provider has its own API configuration (endpoint, credentials, etc.) + */ +export interface ClaudeCompatibleProvider { + /** Unique identifier (uuid) */ + id: string; + /** Display name (e.g., "z.AI GLM (Work)", "MiniMax") */ + name: string; + /** Provider type determines UI screen and default settings */ + providerType: ClaudeCompatibleProviderType; + /** Whether this provider is enabled (models appear in dropdowns) */ + enabled?: boolean; + + // Connection settings + /** ANTHROPIC_BASE_URL - custom API endpoint */ + baseUrl: string; + /** API key sourcing strategy */ + apiKeySource: ApiKeySource; + /** API key value (only required when apiKeySource = 'inline') */ + apiKey?: string; + /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */ + useAuthToken?: boolean; + /** API_TIMEOUT_MS override in milliseconds */ + timeoutMs?: number; + /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */ + disableNonessentialTraffic?: boolean; + + /** Models exposed by this provider (appear in all dropdowns) */ + models: ProviderModel[]; + + /** Provider-specific settings for future extensibility */ + providerSettings?: Record; +} + /** * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint * - * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + * @deprecated Use ClaudeCompatibleProvider instead. This type is kept for + * backward compatibility during migration. */ export interface ClaudeApiProfile { /** Unique identifier (uuid) */ @@ -139,7 +219,7 @@ export interface ClaudeApiProfile { useAuthToken?: boolean; /** API_TIMEOUT_MS override in milliseconds */ timeoutMs?: number; - /** Optional model name mappings */ + /** Optional model name mappings (deprecated - use ClaudeCompatibleProvider.models instead) */ modelMappings?: { /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */ haiku?: string; @@ -152,11 +232,136 @@ export interface ClaudeApiProfile { disableNonessentialTraffic?: boolean; } -/** Known provider templates for quick setup */ +/** + * ClaudeCompatibleProviderTemplate - Template for quick provider setup + * + * Contains pre-configured settings for known Claude-compatible providers. + */ +export interface ClaudeCompatibleProviderTemplate { + /** Template identifier for matching */ + templateId: ClaudeCompatibleProviderType; + /** Display name for the template */ + name: string; + /** Provider type */ + providerType: ClaudeCompatibleProviderType; + /** API base URL */ + baseUrl: string; + /** Default API key source for this template */ + defaultApiKeySource: ApiKeySource; + /** Use auth token instead of API key */ + useAuthToken: boolean; + /** Timeout in milliseconds */ + timeoutMs?: number; + /** Disable non-essential traffic */ + disableNonessentialTraffic?: boolean; + /** Description shown in UI */ + description: string; + /** URL to get API key */ + apiKeyUrl?: string; + /** Default models for this provider */ + defaultModels: ProviderModel[]; +} + +/** Predefined templates for known Claude-compatible providers */ +export const CLAUDE_PROVIDER_TEMPLATES: ClaudeCompatibleProviderTemplate[] = [ + { + templateId: 'anthropic', + name: 'Direct Anthropic', + providerType: 'anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + defaultModels: [ + { id: 'claude-haiku', displayName: 'Claude Haiku', mapsToClaudeModel: 'haiku' }, + { id: 'claude-sonnet', displayName: 'Claude Sonnet', mapsToClaudeModel: 'sonnet' }, + { id: 'claude-opus', displayName: 'Claude Opus', mapsToClaudeModel: 'opus' }, + ], + }, + { + templateId: 'openrouter', + name: 'OpenRouter', + providerType: 'openrouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', + defaultModels: [ + // OpenRouter users manually add model IDs + { + id: 'anthropic/claude-3.5-haiku', + displayName: 'Claude 3.5 Haiku', + mapsToClaudeModel: 'haiku', + }, + { + id: 'anthropic/claude-3.5-sonnet', + displayName: 'Claude 3.5 Sonnet', + mapsToClaudeModel: 'sonnet', + }, + { id: 'anthropic/claude-3-opus', displayName: 'Claude 3 Opus', mapsToClaudeModel: 'opus' }, + ], + }, + { + templateId: 'glm', + name: 'z.AI GLM', + providerType: 'glm', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', + defaultModels: [ + { id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' }, + { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' }, + { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' }, + ], + }, + { + templateId: 'minimax', + name: 'MiniMax', + providerType: 'minimax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + defaultModels: [ + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' }, + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' }, + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' }, + ], + }, + { + templateId: 'minimax', + name: 'MiniMax (China)', + providerType: 'minimax', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + defaultModels: [ + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' }, + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' }, + { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' }, + ], + }, +]; + +/** + * @deprecated Use ClaudeCompatibleProviderTemplate instead + */ export interface ClaudeApiProfileTemplate { name: string; baseUrl: string; - /** Default API key source for this template (user chooses when creating) */ defaultApiKeySource?: ApiKeySource; useAuthToken: boolean; timeoutMs?: number; @@ -166,7 +371,9 @@ export interface ClaudeApiProfileTemplate { apiKeyUrl?: string; } -/** Predefined templates for known Claude-compatible providers */ +/** + * @deprecated Use CLAUDE_PROVIDER_TEMPLATES instead + */ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ { name: 'Direct Anthropic', @@ -229,7 +436,6 @@ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ description: 'MiniMax M2.1 for users in China', apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', }, - // Future: Add AWS Bedrock, Google Vertex, etc. ]; // ============================================================================ @@ -340,8 +546,21 @@ const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = []; * - Claude models: Use thinkingLevel for extended thinking * - Codex models: Use reasoningEffort for reasoning intensity * - Cursor models: Handle thinking internally + * + * For Claude-compatible provider models (GLM, MiniMax, OpenRouter, etc.), + * the providerId field specifies which provider configuration to use. */ export interface PhaseModelEntry { + /** + * Provider ID for Claude-compatible provider models. + * - undefined: Use native Anthropic API (no custom provider) + * - string: Use the specified ClaudeCompatibleProvider by ID + * + * Only required when using models from a ClaudeCompatibleProvider. + * Native Claude models (claude-haiku, claude-sonnet, claude-opus) and + * other providers (Cursor, Codex, OpenCode) don't need this field. + */ + providerId?: string; /** The model to use (supports Claude, Cursor, Codex, OpenCode, and dynamic provider IDs) */ model: ModelId; /** Extended thinking level (only applies to Claude models, defaults to 'none') */ @@ -790,16 +1009,24 @@ export interface GlobalSettings { */ eventHooks?: EventHook[]; - // Claude API Profiles Configuration + // Claude-Compatible Providers Configuration /** - * Claude-compatible API endpoint profiles - * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + * Claude-compatible provider configurations. + * Each provider exposes its models to all model dropdowns in the app. + * Models can be mixed across providers (e.g., use GLM for enhancements, Anthropic for generation). + */ + claudeCompatibleProviders?: ClaudeCompatibleProvider[]; + + // Deprecated Claude API Profiles (kept for migration) + /** + * @deprecated Use claudeCompatibleProviders instead. + * Kept for backward compatibility during migration. */ claudeApiProfiles?: ClaudeApiProfile[]; /** - * Active profile ID (null/undefined = use direct Anthropic API) - * When set, the corresponding profile's settings will be used for Claude API calls + * @deprecated No longer used. Models are selected per-phase via phaseModels. + * Each PhaseModelEntry can specify a providerId for provider-specific models. */ activeClaudeApiProfileId?: string | null; @@ -951,12 +1178,19 @@ export interface ProjectSettings { /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ maxConcurrentAgents?: number; - // Claude API Profile Override (per-project) + // Phase Model Overrides (per-project) + /** + * Override phase model settings for this project. + * Any phase not specified here falls back to global phaseModels setting. + * Allows per-project customization of which models are used for each task. + */ + phaseModelOverrides?: Partial; + + // Deprecated Claude API Profile Override /** - * Override the active Claude API profile for this project. - * - undefined: Use global setting (activeClaudeApiProfileId) - * - null: Explicitly use Direct Anthropic API (no profile) - * - string: Use specific profile by ID + * @deprecated Use phaseModelOverrides instead. + * Models are now selected per-phase via phaseModels/phaseModelOverrides. + * Each PhaseModelEntry can specify a providerId for provider-specific models. */ activeClaudeApiProfileId?: string | null; } @@ -992,7 +1226,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 5; +export const SETTINGS_VERSION = 6; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ @@ -1081,6 +1315,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { skillsSources: ['user', 'project'], enableSubagents: true, subagentsSources: ['user', 'project'], + // New provider system + claudeCompatibleProviders: [], + // Deprecated - kept for migration claudeApiProfiles: [], activeClaudeApiProfileId: null, autoModeByWorktree: {}, diff --git a/libs/utils/src/atomic-writer.ts b/libs/utils/src/atomic-writer.ts index fe07e5ebb..9fc7ff4aa 100644 --- a/libs/utils/src/atomic-writer.ts +++ b/libs/utils/src/atomic-writer.ts @@ -7,6 +7,7 @@ import { secureFs } from '@automaker/platform'; import path from 'path'; +import crypto from 'crypto'; import { createLogger } from './logger.js'; import { mkdirSafe } from './fs-utils.js'; @@ -99,7 +100,9 @@ export async function atomicWriteJson( ): Promise { const { indent = 2, createDirs = false, backupCount = 0 } = options; const resolvedPath = path.resolve(filePath); - const tempPath = `${resolvedPath}.tmp.${Date.now()}`; + // Use timestamp + random suffix to ensure uniqueness even for concurrent writes + const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`; + const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`; // Create parent directories if requested if (createDirs) { diff --git a/package-lock.json b/package-lock.json index 64192c40f..c86ba4aa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6218,6 +6218,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6227,7 +6228,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8438,6 +8439,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11331,7 +11333,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11353,7 +11354,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11375,7 +11375,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11397,7 +11396,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11419,7 +11417,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11441,7 +11438,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11463,7 +11459,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11485,7 +11480,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11507,7 +11501,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11529,7 +11522,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11551,7 +11543,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" },