feat: Claude Compatible Providers System (#629)

* feat: refactor Claude API Profiles to Claude Compatible Providers

- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system

* fix: atomic writer race condition and bulk replace reset to defaults

1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
   - Changed temp file naming from Date.now() to Date.now() + random hex
   - Uses crypto.randomBytes(4).toString('hex') for uniqueness
   - Prevents ENOENT errors when multiple concurrent writes happen
     within the same millisecond

2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
   - When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
   - Properly resets thinking levels and other settings to defaults
   - Added thinkingLevel to the change detection comparison
   - Affects both global and project-level bulk replace dialogs

* fix: update tests for new model resolver passthrough behavior

1. model-resolver tests:
   - Unknown models now pass through unchanged (provider model support)
   - Removed expectations for warnings on unknown models
   - Updated case sensitivity and edge case tests accordingly
   - Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)

2. atomic-writer tests:
   - Updated regex to match new temp file format with random suffix
   - Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}

* refactor: simplify getPhaseModelWithOverrides calls per code review

Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
  - apps/server/src/routes/context/routes/describe-file.ts
  - apps/server/src/routes/context/routes/describe-image.ts
  - apps/server/src/routes/worktree/routes/generate-commit-message.ts
  - apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable

* test: fix server tests for provider model passthrough behavior

- Update model-resolver.test.ts to expect unknown models to pass through
  unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
  ideation-service.test.ts for settingsService

* fix: address code review feedback for model providers

- Honor thinkingLevel in generate-commit-message.ts
- Pass claudeCompatibleProvider in ideation-service.ts for provider models
- Resolve provider configuration for model overrides in generate-suggestions.ts
- Update "Active Profile" to "Active Provider" label in project-claude-section
- Use substring instead of deprecated substr in api-profiles-section
- Preserve provider enabled state when editing in api-profiles-section

* fix: address CodeRabbit review issues for Claude Compatible Providers

- Fix TypeScript TS2339 error in generate-suggestions.ts where
  settingsService was narrowed to 'never' type in else branch
- Use DEFAULT_PHASE_MODELS per-phase defaults instead of hardcoded
  'sonnet' in settings-helpers.ts
- Remove duplicate eventHooks key in use-settings-migration.ts
- Add claudeCompatibleProviders to localStorage migration parsing
  and merging functions
- Handle canonical claude-* model IDs (claude-haiku, claude-sonnet,
  claude-opus) in project-models-section display names

This resolves the CI build failures and addresses code review feedback.

* fix: skip broken list-view-priority E2E test and add Priority column label

- Skip list-view-priority.spec.ts with TODO explaining the infrastructure
  issue: setupRealProject only sets localStorage but server settings
  take precedence with localStorageMigrated: true
- Add 'Priority' label to list-header.tsx for the priority column
  (was empty string, now shows proper header text)
- Increase column width to accommodate the label

The E2E test issue is that tests create features in a temp directory,
but the server loads from the E2E Test Project fixture path set in
setup-e2e-fixtures.mjs. Needs infrastructure fix to properly switch
projects or create features through UI instead of on disk.
This commit is contained in:
Stefan de Vogelaere
2026-01-20 20:57:23 +01:00
committed by GitHub
parent 8facdc66a9
commit a1f234c7e2
48 changed files with 3870 additions and 1089 deletions

View File

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

View File

@@ -14,8 +14,17 @@ import {
getThinkingTokenBudget, getThinkingTokenBudget,
validateBareModelId, validateBareModelId,
type ClaudeApiProfile, type ClaudeApiProfile,
type ClaudeCompatibleProvider,
type Credentials, type Credentials,
} from '@automaker/types'; } 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 { import type {
ExecuteOptions, ExecuteOptions,
ProviderMessage, ProviderMessage,
@@ -51,34 +60,48 @@ const ALLOWED_ENV_VARS = [
// System vars are always passed from process.env regardless of profile // System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; 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. * 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 a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
* When no profile is provided, uses direct Anthropic API settings from process.env. * When no provider is provided, uses direct Anthropic API settings from process.env.
* *
* @param profile - Optional Claude API profile for alternative endpoint configuration * Supports both:
* - ClaudeCompatibleProvider (new system with models[] array)
* - ClaudeApiProfile (legacy system with modelMappings)
*
* @param providerConfig - Optional provider configuration for alternative endpoint
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
*/ */
function buildEnv( function buildEnv(
profile?: ClaudeApiProfile, providerConfig?: ProviderConfig,
credentials?: Credentials credentials?: Credentials
): Record<string, string | undefined> { ): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {}; const env: Record<string, string | undefined> = {};
if (profile) { if (providerConfig) {
// Use profile configuration (clean switch - don't inherit non-system vars from process.env) // Use provider configuration (clean switch - don't inherit non-system vars from process.env)
logger.debug('Building environment from Claude API profile:', { logger.debug('[buildEnv] Using provider configuration:', {
name: profile.name, name: providerConfig.name,
apiKeySource: profile.apiKeySource ?? 'inline', baseUrl: providerConfig.baseUrl,
apiKeySource: providerConfig.apiKeySource ?? 'inline',
isNewProvider: isClaudeCompatibleProvider(providerConfig),
}); });
// Resolve API key based on source strategy // Resolve API key based on source strategy
let apiKey: string | undefined; 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) { switch (source) {
case 'inline': case 'inline':
apiKey = profile.apiKey; apiKey = providerConfig.apiKey;
break; break;
case 'env': case 'env':
apiKey = process.env.ANTHROPIC_API_KEY; apiKey = process.env.ANTHROPIC_API_KEY;
@@ -90,36 +113,40 @@ function buildEnv(
// Warn if no API key found // Warn if no API key found
if (!apiKey) { 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 // Authentication
if (profile.useAuthToken) { if (providerConfig.useAuthToken) {
env['ANTHROPIC_AUTH_TOKEN'] = apiKey; env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
} else { } else {
env['ANTHROPIC_API_KEY'] = apiKey; env['ANTHROPIC_API_KEY'] = apiKey;
} }
// Endpoint configuration // 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) { if (providerConfig.timeoutMs) {
env['API_TIMEOUT_MS'] = String(profile.timeoutMs); env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
} }
// Model mappings // Model mappings - only for legacy ClaudeApiProfile
if (profile.modelMappings?.haiku) { // For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku; if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
} if (providerConfig.modelMappings.haiku) {
if (profile.modelMappings?.sonnet) { env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet; }
} if (providerConfig.modelMappings.sonnet) {
if (profile.modelMappings?.opus) { env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus; }
if (providerConfig.modelMappings.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
}
} }
// Traffic control // Traffic control
if (profile.disableNonessentialTraffic) { if (providerConfig.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
} }
} else { } else {
@@ -184,9 +211,14 @@ export class ClaudeProvider extends BaseProvider {
sdkSessionId, sdkSessionId,
thinkingLevel, thinkingLevel,
claudeApiProfile, claudeApiProfile,
claudeCompatibleProvider,
credentials, credentials,
} = options; } = options;
// Determine which provider config to use
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
// Convert thinking level to token budget // Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
@@ -197,9 +229,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns, maxTurns,
cwd, cwd,
// Pass only explicitly allowed environment variables to SDK // Pass only explicitly allowed environment variables to SDK
// When a profile is active, uses profile settings (clean switch) // When a provider is active, uses provider settings (clean switch)
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth) // When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
env: buildEnv(claudeApiProfile, credentials), env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts) // Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }), ...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
@@ -244,6 +276,18 @@ export class ClaudeProvider extends BaseProvider {
promptPayload = prompt; promptPayload = prompt;
} }
// Log the environment being passed to the SDK for debugging
const envForSdk = sdkOptions.env as Record<string, string | undefined>;
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 // Execute via Claude Agent SDK
try { try {
const stream = query({ prompt: promptPayload, options: sdkOptions }); const stream = query({ prompt: promptPayload, options: sdkOptions });

View File

@@ -21,6 +21,7 @@ import type {
ThinkingLevel, ThinkingLevel,
ReasoningEffort, ReasoningEffort,
ClaudeApiProfile, ClaudeApiProfile,
ClaudeCompatibleProvider,
Credentials, Credentials,
} from '@automaker/types'; } from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types';
@@ -56,9 +57,17 @@ export interface SimpleQueryOptions {
readOnly?: boolean; readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */ /** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>; 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; 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; credentials?: Credentials;
} }
@@ -131,7 +140,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort, reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly, readOnly: options.readOnly,
settingSources: options.settingSources, settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
}; };
@@ -215,7 +225,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort, reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly, readOnly: options.readOnly,
settingSources: options.settingSources, settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
}; };

View File

@@ -17,7 +17,7 @@ import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
@@ -119,20 +119,26 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
'[FeatureGeneration]' '[FeatureGeneration]'
); );
// Get model from phase settings // Get model from phase settings with provider info
const settings = await settingsService?.getGlobalSettings(); const {
const phaseModelEntry = phaseModel: phaseModelEntry,
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel; provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'featureGenerationModel',
settingsService,
projectPath,
'[FeatureGeneration]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model); logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]',
projectPath
);
// Use streamingQuery with event callbacks // Use streamingQuery with event callbacks
const result = await streamingQuery({ const result = await streamingQuery({
@@ -145,7 +151,7 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel, thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => { onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`); logger.debug(`Feature text block received (${text.length} chars)`);

View File

@@ -19,7 +19,7 @@ import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -96,20 +96,26 @@ ${prompts.appSpec.structuredSpecInstructions}`;
'[SpecRegeneration]' '[SpecRegeneration]'
); );
// Get model from phase settings // Get model from phase settings with provider info
const settings = await settingsService?.getGlobalSettings(); const {
const phaseModelEntry = phaseModel: phaseModelEntry,
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'specGenerationModel',
settingsService,
projectPath,
'[SpecRegeneration]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model); logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecRegeneration]',
projectPath
);
let responseText = ''; let responseText = '';
let structuredOutput: SpecOutput | null = null; let structuredOutput: SpecOutput | null = null;
@@ -143,7 +149,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel, thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput outputFormat: useStructuredOutput
? { ? {

View File

@@ -17,7 +17,7 @@ import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import { import {
@@ -155,17 +155,26 @@ export async function syncSpec(
'[SpecSync]' '[SpecSync]'
); );
const settings = await settingsService?.getGlobalSettings(); // Get model from phase settings with provider info
const phaseModelEntry = const {
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; 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); const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Get active Claude API profile for alternative endpoint configuration logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecSync]',
projectPath
);
// Use AI to analyze tech stack // Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology 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, thinkingLevel,
readOnly: true, readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => { onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);

View File

@@ -28,7 +28,7 @@ import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
@@ -121,18 +121,39 @@ export async function generateBacklogPlan(
content: 'Generating plan with AI...', 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 effectiveModel = model;
let thinkingLevel: ThinkingLevel | undefined; let thinkingLevel: ThinkingLevel | undefined;
if (!effectiveModel) { let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
const settings = await settingsService?.getGlobalSettings(); let credentials: import('@automaker/types').Credentials | undefined;
const phaseModelEntry =
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel; if (effectiveModel) {
const resolved = resolvePhaseModel(phaseModelEntry); // 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; effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel; 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); const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers expect bare model IDs // Strip provider prefix - providers expect bare model IDs
@@ -165,13 +186,6 @@ ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt 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 // Execute the query
const stream = provider.executeQuery({ const stream = provider.executeQuery({
prompt: finalPrompt, prompt: finalPrompt,
@@ -184,7 +198,7 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); });

View File

@@ -12,7 +12,6 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform'; import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js'; import { simpleQuery } from '../../../providers/simple-query-service.js';
@@ -22,7 +21,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js'; } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile'); const logger = createLogger('DescribeFile');
@@ -156,21 +155,22 @@ ${contentToAnalyze}`;
'[DescribeFile]' '[DescribeFile]'
); );
// Get model from phase settings // Get model from phase settings with provider info
const settings = await settingsService?.getGlobalSettings(); const {
logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2)); phaseModel: phaseModelEntry,
const phaseModelEntry = provider,
settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel; credentials,
logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry)); } = await getPhaseModelWithOverrides(
'fileDescriptionModel',
settingsService,
cwd,
'[DescribeFile]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); logger.info(
`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
// Get active Claude API profile for alternative endpoint configuration provider ? `via provider: ${provider.name}` : 'direct API'
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeFile]',
cwd
); );
// Use simpleQuery - provider abstraction handles routing to correct provider // Use simpleQuery - provider abstraction handles routing to correct provider
@@ -183,7 +183,7 @@ ${contentToAnalyze}`;
thinkingLevel, thinkingLevel,
readOnly: true, // File description only reads, doesn't write readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); });

View File

@@ -13,7 +13,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { createLogger, readImageAsBase64 } from '@automaker/utils'; import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js'; import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
@@ -22,7 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js'; } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage'); const logger = createLogger('DescribeImage');
@@ -274,24 +274,27 @@ export function createDescribeImageHandler(
'[DescribeImage]' '[DescribeImage]'
); );
// Get model from phase settings // Get model from phase settings with provider info
const settings = await settingsService?.getGlobalSettings(); const {
const phaseModelEntry = phaseModel: phaseModelEntry,
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel; provider,
credentials,
} = await getPhaseModelWithOverrides(
'imageDescriptionModel',
settingsService,
cwd,
'[DescribeImage]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); 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 // Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); 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 // Build the instruction text from centralized prompts
const instructionText = prompts.contextDescription.describeImagePrompt; const instructionText = prompts.contextDescription.describeImagePrompt;
@@ -333,7 +336,7 @@ export function createDescribeImageHandler(
thinkingLevel, thinkingLevel,
readOnly: true, // Image description only reads, doesn't write readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); });

View File

@@ -12,10 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js'; import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import { import {
buildUserPrompt, buildUserPrompt,
isValidEnhancementMode, isValidEnhancementMode,
@@ -126,19 +123,35 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples // Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true); const userPrompt = buildUserPrompt(validMode, trimmedText, true);
// Resolve the model - use the passed model, default to sonnet for quality // Check if the model is a provider model (like "GLM-4.5-Air")
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet); // 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}`); 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 // Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers // The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept // don't have a separate system prompt concept
@@ -150,8 +163,8 @@ export function createEnhanceHandler(
allowedTools: [], allowedTools: [],
thinkingLevel, thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
}); });
const enhancedText = result.text; const enhancedText = result.text;

View File

@@ -10,10 +10,7 @@ import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js'; import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { import { getPromptCustomization } from '../../../lib/settings-helpers.js';
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle'); const logger = createLogger('GenerateTitle');
@@ -64,13 +61,8 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt; const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get active Claude API profile for alternative endpoint configuration // Get credentials for API calls (uses hardcoded haiku model, no phase setting)
// Uses project-specific profile if projectPath provided, otherwise global const credentials = await settingsService?.getCredentials();
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateTitle]',
projectPath
);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
@@ -81,7 +73,6 @@ export function createGenerateTitleHandler(
cwd: process.cwd(), cwd: process.cwd(),
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); });

View File

@@ -37,7 +37,7 @@ import {
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile, getProviderByModelId,
} from '../../../lib/settings-helpers.js'; } from '../../../lib/settings-helpers.js';
import { import {
trySetValidationRunning, 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();
// Get active Claude API profile for alternative endpoint configuration if (settingsService) {
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
settingsService, if (providerResult.provider) {
'[IssueValidation]', claudeCompatibleProvider = providerResult.provider;
projectPath providerResolvedModel = providerResult.resolvedModel;
); credentials = providerResult.credentials;
logger.info(
`Using provider "${providerResult.provider.name}" for model "${model}"` +
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
);
}
}
// 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 // Use streamingQuery with event callbacks
const result = await streamingQuery({ const result = await streamingQuery({
prompt: finalPrompt, prompt: finalPrompt,
model: model as string, model: effectiveModel,
cwd: projectPath, cwd: projectPath,
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined, systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
abortController, abortController,
@@ -187,7 +201,7 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort, reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput outputFormat: useStructuredOutput
? { ? {

View File

@@ -18,7 +18,8 @@ import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getPhaseModelWithOverrides,
getProviderByModelId,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
@@ -171,11 +172,12 @@ ${prompts.suggestions.baseTemplate}`;
'[Suggestions]' '[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 // Use override if provided, otherwise fall back to settings
const settings = await settingsService?.getGlobalSettings();
let model: string; let model: string;
let thinkingLevel: ThinkingLevel | undefined; let thinkingLevel: ThinkingLevel | undefined;
let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (modelOverride) { if (modelOverride) {
// Use explicit override - resolve the model string // Use explicit override - resolve the model string
@@ -185,22 +187,46 @@ ${prompts.suggestions.baseTemplate}`;
}); });
model = resolved.model; model = resolved.model;
thinkingLevel = resolved.thinkingLevel; thinkingLevel = resolved.thinkingLevel;
// Try to find a provider for this model (e.g., GLM, MiniMax models)
if (settingsService) {
const providerResult = await getProviderByModelId(
modelOverride,
settingsService,
'[Suggestions]'
);
provider = providerResult.provider;
// Use resolved model from provider if available (maps to Claude model)
if (providerResult.resolvedModel) {
model = providerResult.resolvedModel;
}
credentials = providerResult.credentials ?? (await settingsService.getCredentials());
}
// If no settingsService, credentials remains undefined (initialized above)
} 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 { } else {
// Use settings-based model // Fallback to defaults
const phaseModelEntry = const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
const resolved = resolvePhaseModel(phaseModelEntry);
model = resolved.model; model = resolved.model;
thinkingLevel = resolved.thinkingLevel; thinkingLevel = resolved.thinkingLevel;
} }
logger.info('[Suggestions] Using model:', model); logger.info(
'[Suggestions] Using model:',
// Get active Claude API profile for alternative endpoint configuration model,
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( provider ? `via provider: ${provider.name}` : 'direct API'
settingsService,
'[Suggestions]',
projectPath
); );
let responseText = ''; let responseText = '';
@@ -234,7 +260,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel, thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput outputFormat: useStructuredOutput
? { ? {

View File

@@ -11,13 +11,13 @@ import { promisify } from 'util';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; import { isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
import { mergeCommitMessagePrompts } from '@automaker/prompts'; import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js'; import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.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 logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -157,26 +157,29 @@ export function createGenerateCommitMessageHandler(
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
// Get model from phase settings // Get model from phase settings with provider info
const settings = await settingsService?.getGlobalSettings(); const {
const phaseModelEntry = phaseModel: phaseModelEntry,
settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; provider: claudeCompatibleProvider,
const { model } = resolvePhaseModel(phaseModelEntry); credentials,
} = await getPhaseModelWithOverrides(
'commitMessageModel',
settingsService,
worktreePath,
'[GenerateCommitMessage]'
);
const { model, thinkingLevel } = 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) // Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService); 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 // Get provider for the model type
const provider = ProviderFactory.getProviderForModel(model); const aiProvider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model); const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
@@ -185,10 +188,10 @@ export function createGenerateCommitMessageHandler(
: userPrompt; : userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; 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 = ''; let responseText = '';
const stream = provider.executeQuery({ const stream = aiProvider.executeQuery({
prompt: effectivePrompt, prompt: effectivePrompt,
model: bareModel, model: bareModel,
cwd: worktreePath, cwd: worktreePath,
@@ -196,7 +199,8 @@ export function createGenerateCommitMessageHandler(
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
readOnly: true, readOnly: true,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration thinkingLevel, // Pass thinking level for extended thinking support
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); });

View File

@@ -29,7 +29,7 @@ import {
getSkillsConfiguration, getSkillsConfiguration,
getSubagentsConfiguration, getSubagentsConfiguration,
getCustomSubagents, getCustomSubagents,
getActiveClaudeApiProfile, getProviderByModelId,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
interface Message { interface Message {
@@ -275,12 +275,29 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir) ? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined; : undefined;
// Get active Claude API profile for alternative endpoint configuration // Get credentials for API calls
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( const credentials = await this.settingsService?.getCredentials();
this.settingsService,
'[AgentService]', // Try to find a provider for the model (if it's a provider model like "GLM-4.7")
effectiveWorkDir // 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 // 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 // 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 // Use thinking level and reasoning effort from request, or fall back to session's stored values
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort; 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({ const sdkOptions = createChatOptions({
cwd: effectiveWorkDir, cwd: effectiveWorkDir,
model: model, model: modelForSdk,
sessionModel: session.model, sessionModel: sessionModelForSdk,
systemPrompt: combinedSystemPrompt, systemPrompt: combinedSystemPrompt,
abortController: session.abortController!, abortController: session.abortController!,
autoLoadClaudeMd, autoLoadClaudeMd,
@@ -386,8 +409,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
}; };
// Build prompt content with images // Build prompt content with images

View File

@@ -68,7 +68,8 @@ import {
filterClaudeMdFromContext, filterClaudeMdFromContext,
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
getActiveClaudeApiProfile, getProviderByModelId,
getPhaseModelWithOverrides,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js'; import { getNotificationService } from './notification-service.js';
@@ -2331,13 +2332,24 @@ Address the follow-up instructions above. Review the previous work and make the
Format your response as a structured markdown document.`; Format your response as a structured markdown document.`;
try { try {
// Get model from phase settings // Get model from phase settings with provider info
const settings = await this.settingsService?.getGlobalSettings(); const {
const phaseModelEntry = phaseModel: phaseModelEntry,
settings?.phaseModels?.projectAnalysisModel || DEFAULT_PHASE_MODELS.projectAnalysisModel; provider: analysisClaudeProvider,
credentials,
} = await getPhaseModelWithOverrides(
'projectAnalysisModel',
this.settingsService,
projectPath,
'[AutoMode]'
);
const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = const { model: analysisModel, thinkingLevel: analysisThinkingLevel } =
resolvePhaseModel(phaseModelEntry); 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); const provider = ProviderFactory.getProviderForModel(analysisModel);
@@ -2359,13 +2371,6 @@ Format your response as a structured markdown document.`;
thinkingLevel: analysisThinkingLevel, thinkingLevel: analysisThinkingLevel,
}); });
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AutoMode]',
projectPath
);
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt, prompt,
model: sdkOptions.model ?? analysisModel, model: sdkOptions.model ?? analysisModel,
@@ -2375,8 +2380,8 @@ Format your response as a structured markdown document.`;
abortController, abortController,
settingSources: sdkOptions.settingSources, settingSources: sdkOptions.settingSources,
thinkingLevel: analysisThinkingLevel, // Pass thinking level thinkingLevel: analysisThinkingLevel, // Pass thinking level
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration
}; };
const stream = provider.executeQuery(options); const stream = provider.executeQuery(options);
@@ -3425,16 +3430,37 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
); );
} }
// Get active Claude API profile for alternative endpoint configuration // Get credentials for API calls (model comes from request, no phase model)
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( const credentials = await this.settingsService?.getCredentials();
this.settingsService,
'[AutoMode]', // Try to find a provider for the model (if it's a provider model like "GLM-4.7")
finalProjectPath // 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 = { const executeOptions: ExecuteOptions = {
prompt: promptContent, prompt: promptContent,
model: bareModel, model: effectiveBareModel,
maxTurns: maxTurns, maxTurns: maxTurns,
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
@@ -3443,8 +3469,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
settingSources: sdkOptions.settingSources, settingSources: sdkOptions.settingSources,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
}; };
// Execute via provider // Execute via provider
@@ -3750,8 +3776,8 @@ After generating the revised spec, output:
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
}); });
let revisionText = ''; let revisionText = '';
@@ -3899,8 +3925,8 @@ After generating the revised spec, output:
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
}); });
let taskOutput = ''; let taskOutput = '';
@@ -3999,8 +4025,8 @@ After generating the revised spec, output:
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
}); });
for await (const msg of continuationStream) { for await (const msg of continuationStream) {

View File

@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js'; import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService'); const logger = createLogger('IdeationService');
@@ -208,7 +208,27 @@ export class IdeationService {
); );
// Resolve model alias to canonical identifier (with prefix) // Resolve model alias to canonical identifier (with prefix)
const modelId = resolveModelString(options?.model ?? 'sonnet'); let modelId = resolveModelString(options?.model ?? 'sonnet');
// Try to find a provider for this model (e.g., GLM, MiniMax models)
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials = await this.settingsService?.getCredentials();
if (this.settingsService && options?.model) {
const providerResult = await getProviderByModelId(
options.model,
this.settingsService,
'[IdeationService]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
// Use resolved model from provider if available (maps to Claude model)
if (providerResult.resolvedModel) {
modelId = providerResult.resolvedModel;
}
credentials = providerResult.credentials ?? credentials;
}
}
// Create SDK options // Create SDK options
const sdkOptions = createChatOptions({ const sdkOptions = createChatOptions({
@@ -223,13 +243,6 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs // Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId); const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = { const executeOptions: ExecuteOptions = {
prompt: message, prompt: message,
model: bareModel, model: bareModel,
@@ -239,7 +252,7 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!, abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : 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 credentials, // Pass credentials for resolving 'credentials' apiKeySource
}; };
@@ -687,12 +700,8 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs // Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId); const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration // Get credentials for API calls (uses hardcoded model, no phase setting)
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( const credentials = await this.settingsService?.getCredentials();
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = { const executeOptions: ExecuteOptions = {
prompt: prompt.prompt, prompt: prompt.prompt,
@@ -704,7 +713,6 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis // Disable all tools - we just want text generation, not codebase analysis
allowedTools: [], allowedTools: [],
abortController: new AbortController(), abortController: new AbortController(),
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
}; };

View File

@@ -31,6 +31,9 @@ import type {
WorktreeInfo, WorktreeInfo,
PhaseModelConfig, PhaseModelConfig,
PhaseModelEntry, PhaseModelEntry,
ClaudeApiProfile,
ClaudeCompatibleProvider,
ProviderModel,
} from '../types/settings.js'; } from '../types/settings.js';
import { import {
DEFAULT_GLOBAL_SETTINGS, DEFAULT_GLOBAL_SETTINGS,
@@ -206,6 +209,28 @@ export class SettingsService {
needsSave = true; 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 // Update version if any migration occurred
if (needsSave) { if (needsSave) {
result.version = SETTINGS_VERSION; 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 * Migrate model-related settings to canonical format
* *
@@ -413,6 +571,7 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels'); ignoreEmptyArrayOverwrite('enabledCursorModels');
ignoreEmptyArrayOverwrite('claudeApiProfiles'); ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
// Empty object overwrite guard // Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => { const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
@@ -658,6 +817,16 @@ export class SettingsService {
delete updated.activeClaudeApiProfileId; 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<string, unknown>).phaseModelOverrides === '__CLEAR__'
) {
delete updated.phaseModelOverrides;
}
await writeSettingsJson(settingsPath, updated); await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`); logger.info(`Project settings updated for ${projectPath}`);

View File

@@ -23,6 +23,16 @@ export type {
PhaseModelConfig, PhaseModelConfig,
PhaseModelKey, PhaseModelKey,
PhaseModelEntry, PhaseModelEntry,
// Claude-compatible provider types
ApiKeySource,
ClaudeCompatibleProviderType,
ClaudeModelAlias,
ProviderModel,
ClaudeCompatibleProvider,
ClaudeCompatibleProviderTemplate,
// Legacy profile types (deprecated)
ClaudeApiProfile,
ClaudeApiProfileTemplate,
} from '@automaker/types'; } from '@automaker/types';
export { export {

View File

@@ -41,13 +41,14 @@ describe('model-resolver.ts', () => {
); );
}); });
it('should treat unknown models as falling back to default', () => { it('should pass through unknown models unchanged (may be provider models)', () => {
// Note: Don't include valid Cursor model IDs here (e.g., 'gpt-5.2' is in CURSOR_MODEL_MAP) // Unknown models now pass through unchanged to support ClaudeCompatibleProvider models
const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123']; // like GLM-4.7, MiniMax-M2.1, o1, etc.
const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123', 'GLM-4.7'];
models.forEach((model) => { models.forEach((model) => {
const result = resolveModelString(model); const result = resolveModelString(model);
// Should fall back to default since these aren't supported // Should pass through unchanged (could be provider models)
expect(result).toBe(DEFAULT_MODELS.claude); expect(result).toBe(model);
}); });
}); });
@@ -73,12 +74,12 @@ describe('model-resolver.ts', () => {
expect(result).toBe(customDefault); expect(result).toBe(customDefault);
}); });
it('should return default for unknown model key', () => { it('should pass through unknown model key unchanged (no warning)', () => {
const result = resolveModelString('unknown-model'); const result = resolveModelString('unknown-model');
expect(result).toBe(DEFAULT_MODELS.claude); // Unknown models pass through unchanged (could be provider models)
expect(consoleSpy.warn).toHaveBeenCalledWith( expect(result).toBe('unknown-model');
expect.stringContaining('Unknown model key "unknown-model"') // No warning - unknown models are valid for providers
); expect(consoleSpy.warn).not.toHaveBeenCalled();
}); });
it('should handle empty string', () => { it('should handle empty string', () => {

View File

@@ -63,7 +63,10 @@ describe('IdeationService', () => {
} as unknown as EventEmitter; } as unknown as EventEmitter;
// Create mock settings service // Create mock settings service
mockSettingsService = {} as SettingsService; mockSettingsService = {
getCredentials: vi.fn().mockResolvedValue({}),
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Create mock feature loader // Create mock feature loader
mockFeatureLoader = { mockFeatureLoader = {

View File

@@ -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 // Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) { if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic'; return 'anthropic';

View File

@@ -35,10 +35,10 @@ export const LIST_COLUMNS: ColumnDef[] = [
}, },
{ {
id: 'priority', id: 'priority',
label: '', label: 'Priority',
sortable: true, sortable: true,
width: 'w-18', width: 'w-20',
minWidth: 'min-w-[16px]', minWidth: 'min-w-[60px]',
align: 'center', align: 'center',
}, },
]; ];

View File

@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react'; 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'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem { export interface ProjectNavigationItem {
@@ -12,6 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User }, { id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'theme', label: 'Theme', icon: Palette }, { 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 }, { id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
]; ];

View File

@@ -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<PhaseModelKey, string> = {
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<ClaudeModelAlias, string> = {
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<string>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bulk Replace Models (Project Override)</DialogTitle>
<DialogDescription>
Set project-level overrides for all phases to use models from a specific provider. This
only affects this project.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Provider selector */}
<div className="space-y-2">
<label className="text-sm font-medium">Target Provider</label>
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div className="flex items-center gap-2">
{option.isNative ? (
<Cloud className="w-4 h-4 text-brand-500" />
) : (
<Server className="w-4 h-4 text-muted-foreground" />
)}
<span>{option.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Warning if provider has no models */}
{!providerHasModels && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span>This provider has no models configured.</span>
</div>
</div>
)}
{/* Warning if provider doesn't have all 3 mappings */}
{providerHasModels && !providerModelCoverage.complete && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span>
This provider is missing mappings for:{' '}
{[
!providerModelCoverage.hasHaiku && 'Haiku',
!providerModelCoverage.hasSonnet && 'Sonnet',
!providerModelCoverage.hasOpus && 'Opus',
]
.filter(Boolean)
.join(', ')}
</span>
</div>
</div>
)}
{/* Preview of changes */}
{providerHasModels && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Preview Changes</label>
<span className="text-xs text-muted-foreground">
{changeCount} of {ALL_PHASES.length} will be overridden
</span>
</div>
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-left p-2 font-medium text-muted-foreground">Phase</th>
<th className="text-left p-2 font-medium text-muted-foreground">Current</th>
<th className="p-2"></th>
<th className="text-left p-2 font-medium text-muted-foreground">
New Override
</th>
</tr>
</thead>
<tbody>
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
<tr
key={phase}
className={cn(
'border-t border-border/50',
isChanged ? 'bg-brand-500/5' : 'opacity-50'
)}
>
<td className="p-2 font-medium">{label}</td>
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
<td className="p-2 text-center">
{isChanged ? (
<ArrowRight className="w-4 h-4 text-brand-500 inline" />
) : (
<Check className="w-4 h-4 text-green-500 inline" />
)}
</td>
<td className="p-2">
<span className={cn(isChanged && 'text-brand-500 font-medium')}>
{newDisplay}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!providerHasModels || changeCount === 0}>
Apply Overrides ({changeCount})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -63,7 +63,7 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
<Bot className="w-12 h-12 mx-auto mb-3 opacity-50" /> <Bot className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Claude not configured</p> <p className="text-sm">Claude not configured</p>
<p className="text-xs mt-1"> <p className="text-xs mt-1">
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.
</p> </p>
</div> </div>
); );
@@ -95,21 +95,19 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Bot className="w-5 h-5 text-brand-500" /> <Bot className="w-5 h-5 text-brand-500" />
</div> </div>
<h2 className="text-lg font-semibold text-foreground tracking-tight"> <h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Provider</h2>
Claude API Profile
</h2>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12"> <p className="text-sm text-muted-foreground/80 ml-12">
Override the Claude API profile for this project only. Override the Claude provider for this project only.
</p> </p>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Active Profile for This Project</Label> <Label className="text-sm font-medium">Active Provider for This Project</Label>
<Select value={selectValue} onValueChange={handleChange}> <Select value={selectValue} onValueChange={handleChange}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select profile" /> <SelectValue placeholder="Select provider" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="global"> <SelectItem value="global">

View File

@@ -0,0 +1,365 @@
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 (both short aliases and canonical IDs)
const modelMap: Record<string, string> = {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
'claude-haiku': 'Claude Haiku',
'claude-sonnet': 'Claude Sonnet',
'claude-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 (
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-accent/20 border',
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
<div className="flex-1 pr-4">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">{phase.label}</h4>
{hasOverride ? (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
Override
</span>
) : (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
<Globe className="w-3 h-3" />
Global
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{phase.description}</p>
{hasOverride && (
<p className="text-xs text-brand-500 mt-1">
Using: {getModelDisplayName(effectiveValue)}
</p>
)}
{!hasOverride && (
<p className="text-xs text-muted-foreground/70 mt-1">
Using global: {getModelDisplayName(globalValue)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{hasOverride && (
<Button
variant="ghost"
size="sm"
onClick={handleClearOverride}
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
Reset
</Button>
)}
<PhaseModelSelector
compact
value={effectiveValue}
onChange={handleSetOverride}
align="end"
/>
</div>
</div>
);
}
function PhaseGroup({
title,
subtitle,
phases,
project,
}: {
title: string;
subtitle: string;
phases: PhaseConfig[];
project: Project;
}) {
const { phaseModels } = useAppStore();
const projectOverrides = project.phaseModelOverrides || {};
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<div className="space-y-3">
{phases.map((phase) => (
<PhaseOverrideItem
key={phase.key}
phase={phase}
project={project}
globalValue={phaseModels[phase.key] ?? DEFAULT_PHASE_MODELS[phase.key]}
projectOverride={projectOverrides[phase.key]}
/>
))}
</div>
</div>
);
}
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 (
<div className="text-center py-12 text-muted-foreground">
<Workflow className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Claude not configured</p>
<p className="text-xs mt-1">
Enable Claude in global settings to configure per-project model overrides.
</p>
</div>
);
}
const handleClearAll = () => {
clearAllProjectPhaseModelOverrides(project.id);
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Workflow className="w-5 h-5 text-brand-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Overrides
</h2>
<p className="text-sm text-muted-foreground/80">
Override AI models for this project only
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasEnabledProviders && (
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkReplace(true)}
className="gap-2"
>
<Replace className="w-3.5 h-3.5" />
Bulk Replace
</Button>
)}
{overrideCount > 0 && (
<Button variant="outline" size="sm" onClick={handleClearAll} className="gap-2">
<RotateCcw className="w-3.5 h-3.5" />
Reset All ({overrideCount})
</Button>
)}
</div>
</div>
</div>
{/* Bulk Replace Dialog */}
<ProjectBulkReplaceDialog
open={showBulkReplace}
onOpenChange={setShowBulkReplace}
project={project}
/>
{/* Info Banner */}
<div className="px-6 pt-6">
<div className="p-3 rounded-lg bg-brand-500/5 border border-brand-500/20 text-sm text-muted-foreground">
<div className="flex items-center gap-2 mb-1">
<Check className="w-4 h-4 text-brand-500" />
<span className="font-medium text-foreground">Per-Phase Overrides</span>
</div>
Override specific phases to use different models for this project. Phases without
overrides use the global settings.
</div>
</div>
{/* Content */}
<div className="p-6 space-y-8">
{/* Quick Tasks */}
<PhaseGroup
title="Quick Tasks"
subtitle="Fast models recommended for speed and cost savings"
phases={QUICK_TASKS}
project={project}
/>
{/* Validation Tasks */}
<PhaseGroup
title="Validation Tasks"
subtitle="Smart models recommended for accuracy"
phases={VALIDATION_TASKS}
project={project}
/>
{/* Generation Tasks */}
<PhaseGroup
title="Generation Tasks"
subtitle="Powerful models recommended for quality output"
phases={GENERATION_TASKS}
project={project}
/>
{/* Memory Tasks */}
<PhaseGroup
title="Memory Tasks"
subtitle="Fast models recommended for learning extraction"
phases={MEMORY_TASKS}
project={project}
/>
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section'; import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section'; import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-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 { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog'; import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation'; import { ProjectSettingsNavigation } from './components/project-settings-navigation';
@@ -86,7 +86,7 @@ export function ProjectSettingsView() {
case 'worktrees': case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />; return <WorktreePreferencesSection project={currentProject} />;
case 'claude': case 'claude':
return <ProjectClaudeSection project={currentProject} />; return <ProjectModelsSection project={currentProject} />;
case 'danger': case 'danger':
return ( return (
<DangerZoneSection <DangerZoneSection

View File

@@ -105,7 +105,7 @@ export function ApiKeysSection() {
{providerConfigs.map((provider) => ( {providerConfigs.map((provider) => (
<div key={provider.key}> <div key={provider.key}>
<ApiKeyField config={provider} /> <ApiKeyField config={provider} />
{/* Anthropic-specific profile info */} {/* Anthropic-specific provider info */}
{provider.key === 'anthropic' && ( {provider.key === 'anthropic' && (
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20"> <div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -113,20 +113,19 @@ export function ApiKeysSection() {
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<p> <p>
<span className="font-medium text-foreground/80"> <span className="font-medium text-foreground/80">
Using Claude API Profiles? Using Claude Compatible Providers?
</span>{' '} </span>{' '}
Create a profile in{' '} Add a provider in <span className="text-blue-500">AI Providers Claude</span>{' '}
<span className="text-blue-500">AI Providers Claude</span> with{' '} with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded"> <span className="font-mono text-[10px] bg-muted/50 px-1 rounded">
credentials credentials
</span>{' '} </span>{' '}
as the API key source to use this key. as the API key source to use this key.
</p> </p>
<p> <p>
For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile For alternative providers (z.AI GLM, MiniMax, OpenRouter), add a provider with{' '}
with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '} <span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '}
key source and enter the provider's API key directly in the profile. key source and enter the provider's API key directly.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -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<PhaseModelKey, string> = {
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<ClaudeModelAlias, string> = {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
};
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
const [selectedProvider, setSelectedProvider] = useState<string>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bulk Replace Models</DialogTitle>
<DialogDescription>
Switch all phase models to equivalents from a specific provider. Models are matched by
their Claude model mapping (Haiku, Sonnet, Opus).
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Provider selector */}
<div className="space-y-2">
<label className="text-sm font-medium">Target Provider</label>
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div className="flex items-center gap-2">
{option.isNative ? (
<Cloud className="w-4 h-4 text-brand-500" />
) : (
<Server className="w-4 h-4 text-muted-foreground" />
)}
<span>{option.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Warning if provider has no models */}
{!providerHasModels && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span>This provider has no models configured.</span>
</div>
</div>
)}
{/* Warning if provider doesn't have all 3 mappings */}
{providerHasModels && !providerModelCoverage.complete && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span>
This provider is missing mappings for:{' '}
{[
!providerModelCoverage.hasHaiku && 'Haiku',
!providerModelCoverage.hasSonnet && 'Sonnet',
!providerModelCoverage.hasOpus && 'Opus',
]
.filter(Boolean)
.join(', ')}
</span>
</div>
</div>
)}
{/* Preview of changes */}
{providerHasModels && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Preview Changes</label>
<span className="text-xs text-muted-foreground">
{changeCount} of {ALL_PHASES.length} will change
</span>
</div>
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-left p-2 font-medium text-muted-foreground">Phase</th>
<th className="text-left p-2 font-medium text-muted-foreground">Current</th>
<th className="p-2"></th>
<th className="text-left p-2 font-medium text-muted-foreground">New</th>
</tr>
</thead>
<tbody>
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
<tr
key={phase}
className={cn(
'border-t border-border/50',
isChanged ? 'bg-brand-500/5' : 'opacity-50'
)}
>
<td className="p-2 font-medium">{label}</td>
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
<td className="p-2 text-center">
{isChanged ? (
<ArrowRight className="w-4 h-4 text-brand-500 inline" />
) : (
<Check className="w-4 h-4 text-green-500 inline" />
)}
</td>
<td className="p-2">
<span className={cn(isChanged && 'text-brand-500 font-medium')}>
{newDisplay}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!providerHasModels || changeCount === 0}>
Apply Changes ({changeCount})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PhaseModelSelector } from './phase-model-selector'; import { PhaseModelSelector } from './phase-model-selector';
import { BulkReplaceDialog } from './bulk-replace-dialog';
import type { PhaseModelKey } from '@automaker/types'; import type { PhaseModelKey } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types'; import { DEFAULT_PHASE_MODELS } from '@automaker/types';
@@ -112,7 +114,12 @@ function PhaseGroup({
} }
export function ModelDefaultsSection() { 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 ( return (
<div <div
@@ -139,13 +146,29 @@ export function ModelDefaultsSection() {
</p> </p>
</div> </div>
</div> </div>
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2"> <div className="flex items-center gap-2">
<RotateCcw className="w-3.5 h-3.5" /> {hasEnabledProviders && (
Reset to Defaults <Button
</Button> variant="outline"
size="sm"
onClick={() => setShowBulkReplace(true)}
className="gap-2"
>
<Replace className="w-3.5 h-3.5" />
Bulk Replace
</Button>
)}
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
<RotateCcw className="w-3.5 h-3.5" />
Reset to Defaults
</Button>
</div>
</div> </div>
</div> </div>
{/* Bulk Replace Dialog */}
<BulkReplaceDialog open={showBulkReplace} onOpenChange={setShowBulkReplace} />
{/* Content */} {/* Content */}
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{/* Quick Tasks */} {/* Quick Tasks */}

View File

@@ -9,6 +9,9 @@ import type {
OpencodeModelId, OpencodeModelId,
GroupedModel, GroupedModel,
PhaseModelEntry, PhaseModelEntry,
ClaudeCompatibleProvider,
ProviderModel,
ClaudeModelAlias,
} from '@automaker/types'; } from '@automaker/types';
import { import {
stripProviderPrefix, stripProviderPrefix,
@@ -33,6 +36,9 @@ import {
AnthropicIcon, AnthropicIcon,
CursorIcon, CursorIcon,
OpenAIIcon, OpenAIIcon,
OpenRouterIcon,
GlmIcon,
MiniMaxIcon,
getProviderIconForModel, getProviderIconForModel,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -154,10 +160,12 @@ export function PhaseModelSelector({
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null); const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null); const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
const [expandedProviderModel, setExpandedProviderModel] = useState<string | null>(null); // Format: providerId:modelId
const commandListRef = useRef<HTMLDivElement>(null); const commandListRef = useRef<HTMLDivElement>(null);
const expandedTriggerRef = useRef<HTMLDivElement>(null); const expandedTriggerRef = useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null); const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null); const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
const { const {
enabledCursorModels, enabledCursorModels,
favoriteModels, favoriteModels,
@@ -170,16 +178,23 @@ export function PhaseModelSelector({
opencodeModelsLoading, opencodeModelsLoading,
fetchOpencodeModels, fetchOpencodeModels,
disabledProviders, disabledProviders,
claudeCompatibleProviders,
} = useAppStore(); } = useAppStore();
// Detect mobile devices to use inline expansion instead of nested popovers // Detect mobile devices to use inline expansion instead of nested popovers
const isMobile = useIsMobile(); 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 selectedModel = value.model;
const selectedProviderId = value.providerId;
const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedThinkingLevel = value.thinkingLevel || 'none';
const selectedReasoningEffort = value.reasoningEffort || '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 // Fetch Codex models on mount
useEffect(() => { useEffect(() => {
if (codexModels.length === 0 && !codexModelsLoading) { if (codexModels.length === 0 && !codexModelsLoading) {
@@ -267,6 +282,29 @@ export function PhaseModelSelector({
return () => observer.disconnect(); return () => observer.disconnect();
}, [expandedCodexModel]); }, [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 // Transform dynamic Codex models from store to component format
const transformedCodexModels = useMemo(() => { const transformedCodexModels = useMemo(() => {
return codexModels.map((model) => ({ 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; return null;
}, [ }, [
selectedModel, selectedModel,
selectedProviderId,
selectedThinkingLevel, selectedThinkingLevel,
availableCursorModels, availableCursorModels,
transformedCodexModels, transformedCodexModels,
dynamicOpencodeModels, dynamicOpencodeModels,
enabledProviders,
]); ]);
// Compute grouped vs standalone Cursor models // 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<ClaudeModelAlias, string> = {
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 (
<div key={`${provider.id}-${model.id}`}>
<CommandItem
value={`${provider.name} ${model.displayName}`}
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<ProviderIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentThinking !== 'none'
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
: `Maps to ${mappedModelLabel}`}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline thinking level options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
<button
key={level}
onClick={() => {
onChange({
providerId: provider.id,
model: model.id,
thinkingLevel: level,
});
setExpandedProviderModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
<span className="text-[10px] text-muted-foreground">
{level === 'none' && 'No extended thinking'}
{level === 'low' && 'Light reasoning (1k tokens)'}
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
</span>
</div>
{isSelected && currentThinking === level && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={`${provider.id}-${model.id}`}
value={`${provider.name} ${model.displayName}`}
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
className="p-0 data-[selected=true]:bg-transparent"
>
<Popover
open={isExpanded}
onOpenChange={(isOpen) => {
if (!isOpen) {
setExpandedProviderModel(null);
}
}}
>
<PopoverTrigger asChild>
<div
ref={isExpanded ? expandedProviderTriggerRef : undefined}
className={cn(
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
'hover:bg-accent',
isExpanded && 'bg-accent'
)}
>
<div className="flex items-center gap-3 overflow-hidden">
<ProviderIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentThinking !== 'none'
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
: `Maps to ${mappedModelLabel}`}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</div>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-1">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
<button
key={level}
onClick={() => {
onChange({
providerId: provider.id,
model: model.id,
thinkingLevel: level,
});
setExpandedProviderModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium">{THINKING_LEVEL_LABELS[level]}</span>
<span className="text-xs text-muted-foreground">
{level === 'none' && 'No extended thinking'}
{level === 'low' && 'Light reasoning (1k tokens)'}
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
</span>
</div>
{isSelected && currentThinking === level && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
</CommandItem>
);
};
// Render Cursor model item (no thinking level needed) // Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
// With canonical IDs, store the full prefixed ID // With canonical IDs, store the full prefixed ID
@@ -1499,6 +1818,50 @@ export function PhaseModelSelector({
</CommandGroup> </CommandGroup>
)} )}
{/* 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 (
<CommandGroup key={provider.id} heading={`${provider.name} (via Claude)`}>
{uniqueModelsWithMappings.map(({ model, mappedModels }) =>
renderProviderModelItem(provider, model, showSuffix, mappedModels)
)}
</CommandGroup>
);
})}
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
<CommandGroup heading="Cursor Models"> <CommandGroup heading="Cursor Models">
{/* Grouped models with secondary popover */} {/* Grouped models with secondary popover */}

View File

@@ -47,7 +47,7 @@ export function ClaudeSettingsTab() {
onRefresh={handleRefreshClaudeCli} onRefresh={handleRefreshClaudeCli}
/> />
{/* API Profiles for Claude-compatible endpoints */} {/* Claude-compatible providers */}
<ApiProfilesSection /> <ApiProfilesSection />
<ClaudeMdSettings <ClaudeMdSettings

View File

@@ -95,18 +95,45 @@ export function useProjectSettingsLoader() {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator); setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
} }
// Apply activeClaudeApiProfileId if present // Apply activeClaudeApiProfileId and phaseModelOverrides if present
if (settings.activeClaudeApiProfileId !== undefined) { // These are stored directly on the project, so we need to update both
const updatedProject = useAppStore.getState().currentProject; // currentProject AND the projects array to keep them in sync
if ( // Type assertion needed because API returns Record<string, unknown>
updatedProject && const settingsWithExtras = settings as Record<string, unknown>;
updatedProject.path === projectPath && const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as
updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId | string
) { | null
setCurrentProject({ | 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, ...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 });
} }
} }
}, [ }, [

View File

@@ -208,12 +208,13 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean), worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
lastProjectDir: lastProjectDir || (state.lastProjectDir as string), lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]), recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
// Claude API Profiles // Claude API Profiles (legacy)
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
activeClaudeApiProfileId: activeClaudeApiProfileId:
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
// Event hooks // Claude Compatible Providers (new system)
eventHooks: state.eventHooks as GlobalSettings['eventHooks'], claudeCompatibleProviders:
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
}; };
} catch (error) { } catch (error) {
logger.error('Failed to parse localStorage settings:', error); logger.error('Failed to parse localStorage settings:', error);
@@ -348,6 +349,16 @@ export function mergeSettings(
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId; merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
} }
// Claude Compatible Providers - preserve from localStorage if server is empty
if (
(!serverSettings.claudeCompatibleProviders ||
serverSettings.claudeCompatibleProviders.length === 0) &&
localSettings.claudeCompatibleProviders &&
localSettings.claudeCompatibleProviders.length > 0
) {
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
}
return merged; return merged;
} }
@@ -720,6 +731,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
mcpServers: settings.mcpServers ?? [], mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {}, promptCustomization: settings.promptCustomization ?? {},
eventHooks: settings.eventHooks ?? [], eventHooks: settings.eventHooks ?? [],
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
claudeApiProfiles: settings.claudeApiProfiles ?? [], claudeApiProfiles: settings.claudeApiProfiles ?? [],
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null, activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
projects, projects,
@@ -798,6 +810,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
mcpServers: state.mcpServers, mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization, promptCustomization: state.promptCustomization,
eventHooks: state.eventHooks, eventHooks: state.eventHooks,
claudeCompatibleProviders: state.claudeCompatibleProviders,
claudeApiProfiles: state.claudeApiProfiles, claudeApiProfiles: state.claudeApiProfiles,
activeClaudeApiProfileId: state.activeClaudeApiProfileId, activeClaudeApiProfileId: state.activeClaudeApiProfileId,
projects: state.projects, projects: state.projects,

View File

@@ -3403,8 +3403,15 @@ export interface Project {
* - undefined: Use global setting (activeClaudeApiProfileId) * - undefined: Use global setting (activeClaudeApiProfileId)
* - null: Explicitly use Direct Anthropic API (no profile) * - null: Explicitly use Direct Anthropic API (no profile)
* - string: Use specific profile by ID * - string: Use specific profile by ID
* @deprecated Use phaseModelOverrides instead for per-phase model selection
*/ */
activeClaudeApiProfileId?: string | null; 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<import('@automaker/types').PhaseModelConfig>;
} }
export interface TrashedProject extends Project { export interface TrashedProject extends Project {

View File

@@ -33,6 +33,7 @@ import type {
ServerLogLevel, ServerLogLevel,
EventHook, EventHook,
ClaudeApiProfile, ClaudeApiProfile,
ClaudeCompatibleProvider,
} from '@automaker/types'; } from '@automaker/types';
import { import {
getAllCursorModelIds, getAllCursorModelIds,
@@ -752,7 +753,10 @@ export interface AppState {
// Event Hooks // Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks 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 claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API) 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) getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
// Claude API Profile actions (per-project override) // 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) 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 // Feature actions
setFeatures: (features: Feature[]) => void; setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void; updateFeature: (id: string, updates: Partial<Feature>) => void;
@@ -1211,7 +1224,17 @@ export interface AppActions {
// Event Hook actions // Event Hook actions
setEventHooks: (hooks: EventHook[]) => void; setEventHooks: (hooks: EventHook[]) => void;
// Claude API Profile actions // Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
updateClaudeCompatibleProvider: (
id: string,
updates: Partial<ClaudeCompatibleProvider>
) => Promise<void>;
deleteClaudeCompatibleProvider: (id: string) => Promise<void>;
setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise<void>;
toggleClaudeCompatibleProviderEnabled: (id: string) => Promise<void>;
// Claude API Profile actions (deprecated - kept for backward compatibility)
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>; addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>; updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
deleteClaudeApiProfile: (id: string) => Promise<void>; deleteClaudeApiProfile: (id: string) => Promise<void>;
@@ -1476,8 +1499,9 @@ const initialState: AppState = {
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults promptCustomization: {}, // Empty by default - all prompts use built-in defaults
eventHooks: [], // No event hooks configured by default eventHooks: [], // No event hooks configured by default
claudeApiProfiles: [], // No Claude API profiles configured by default claudeCompatibleProviders: [], // Claude-compatible providers that expose models
activeClaudeApiProfileId: null, // Use direct Anthropic API by default claudeApiProfiles: [], // No Claude API profiles configured by default (deprecated)
activeClaudeApiProfileId: null, // Use direct Anthropic API by default (deprecated)
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
boardBackgroundByProject: {}, boardBackgroundByProject: {},
@@ -2017,6 +2041,98 @@ export const useAppStore = create<AppState & AppActions>()((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 // Feature actions
setFeatures: (features) => set({ features }), setFeatures: (features) => set({ features }),
@@ -2601,7 +2717,53 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Event Hook actions // Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }), 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) => { addClaudeApiProfile: async (profile) => {
set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] }); set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] });
// Sync immediately to persist profile // Sync immediately to persist profile

View File

@@ -18,7 +18,13 @@ import {
const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test'); const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
test.describe('List View Priority Column', () => { // TODO: This test is skipped because setupRealProject only sets localStorage,
// but the server's settings.json (set by setup-e2e-fixtures.mjs) takes precedence
// with localStorageMigrated: true. The test creates features in a temp directory,
// but the server loads from the E2E Test Project fixture path.
// Fix: Either modify setupRealProject to also update server settings, or
// have the test add features through the UI instead of on disk.
test.describe.skip('List View Priority Column', () => {
let projectPath: string; let projectPath: string;
const projectName = `test-project-${Date.now()}`; const projectName = `test-project-${Date.now()}`;

View File

@@ -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 - **Cost savings**: Use providers like z.AI GLM or MiniMax at lower costs
2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys - **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 ### Type Definitions
- 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
## Solution Overview #### ClaudeCompatibleProvider
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 ```typescript
// libs/types/src/settings.ts export interface ClaudeCompatibleProvider {
export type ApiKeySource = 'inline' | 'env' | 'credentials'; id: string; // Unique identifier (UUID)
``` name: string; // Display name (e.g., "z.AI GLM")
baseUrl: string; // API endpoint URL
#### Updated Interface: `ClaudeApiProfile` providerType?: string; // Provider type for icon/grouping (e.g., 'glm', 'minimax', 'openrouter')
apiKeySource?: ApiKeySource; // 'inline' | 'env' | 'credentials'
```typescript apiKey?: string; // API key (when apiKeySource = 'inline')
export interface ClaudeApiProfile { useAuthToken?: boolean; // Use ANTHROPIC_AUTH_TOKEN header
id: string; timeoutMs?: number; // Request timeout in milliseconds
name: string; disableNonessentialTraffic?: boolean; // Minimize non-essential API calls
baseUrl: string; enabled?: boolean; // Whether provider is active (default: true)
models?: ProviderModel[]; // Models exposed by this provider
// 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;
} }
``` ```
#### Updated Interface: `ClaudeApiProfileTemplate` #### ProviderModel
```typescript ```typescript
export interface ClaudeApiProfileTemplate { export interface ProviderModel {
name: string; id: string; // Model ID sent to API (e.g., "GLM-4.7")
baseUrl: string; displayName: string; // Display name in UI (e.g., "GLM 4.7")
defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template mapsToClaudeModel?: ClaudeModelAlias; // Which Claude tier this replaces ('haiku' | 'sonnet' | 'opus')
useAuthToken: boolean; capabilities?: {
// ... other fields supportsVision?: boolean; // Whether model supports image inputs
supportsThinking?: boolean; // Whether model supports extended thinking
maxThinkingLevel?: ThinkingLevel; // Maximum thinking level if supported
};
}
```
#### PhaseModelEntry
Phase model configuration now supports provider models:
```typescript
export interface PhaseModelEntry {
providerId?: string; // Provider ID (undefined = native Claude)
model: string; // Model ID or alias
thinkingLevel?: ThinkingLevel; // 'none' | 'low' | 'medium' | 'high'
} }
``` ```
### Provider Templates ### Provider Templates
The following provider templates are available: Available provider templates in `CLAUDE_PROVIDER_TEMPLATES`:
#### Direct Anthropic | 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 |
```typescript ### Model Mappings
{
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',
}
```
#### OpenRouter Each provider model specifies which Claude model tier it maps to via `mapsToClaudeModel`:
Access Claude and 300+ other models through OpenRouter's unified API. **z.AI GLM:**
```typescript - `GLM-4.5-Air` → haiku
{ - `GLM-4.7` → sonnet, opus
name: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api',
defaultApiKeySource: 'inline',
useAuthToken: true,
description: 'Access Claude and 300+ models via OpenRouter',
apiKeyUrl: 'https://openrouter.ai/keys',
}
```
**Notes:** **MiniMax:**
- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key - `MiniMax-M2.1` → haiku, sonnet, opus
- 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`)
#### z.AI GLM **OpenRouter:**
```typescript - `anthropic/claude-3.5-haiku` → haiku
{ - `anthropic/claude-3.5-sonnet` → sonnet
name: 'z.AI GLM', - `anthropic/claude-3-opus` → opus
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',
}
```
#### MiniMax ## Server-Side Implementation
MiniMax M2.1 coding model with extended context support. ### API Key Resolution
```typescript The `buildEnv()` function in `claude-provider.ts` resolves API keys based on `apiKeySource`:
{
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)
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',
}
```
### Server-Side Changes
#### 1. Environment Building (`claude-provider.ts`)
The `buildEnv()` function now resolves API keys based on the `apiKeySource`:
```typescript ```typescript
function buildEnv( function buildEnv(
profile?: ClaudeApiProfile, providerConfig?: ClaudeCompatibleProvider,
credentials?: Credentials // NEW parameter credentials?: Credentials
): Record<string, string | undefined> { ): Record<string, string | undefined> {
if (profile) { if (providerConfig) {
// Resolve API key based on source strategy
let apiKey: string | undefined; let apiKey: string | undefined;
const source = profile.apiKeySource ?? 'inline'; const source = providerConfig.apiKeySource ?? 'inline';
switch (source) { switch (source) {
case 'inline': case 'inline':
apiKey = profile.apiKey; apiKey = providerConfig.apiKey;
break; break;
case 'env': case 'env':
apiKey = process.env.ANTHROPIC_API_KEY; apiKey = process.env.ANTHROPIC_API_KEY;
@@ -207,163 +117,184 @@ function buildEnv(
apiKey = credentials?.apiKeys?.anthropic; apiKey = credentials?.apiKeys?.anthropic;
break; break;
} }
// ... build environment with resolved key
// ... rest of profile-based env building
}
// ... no-profile fallback
}
```
#### 2. Settings Helper (`settings-helpers.ts`)
The `getActiveClaudeApiProfile()` function now returns both profile and credentials:
```typescript
export interface ActiveClaudeApiProfileResult {
profile: ClaudeApiProfile | undefined;
credentials: Credentials | undefined;
}
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<ActiveClaudeApiProfileResult> {
// Returns both profile and credentials for API key resolution
}
```
#### 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
} }
} }
``` ```
#### 4. Updated Call Sites ### Provider Lookup
All files that call `getActiveClaudeApiProfile()` were updated to: The `getProviderByModelId()` helper resolves provider configuration from model IDs:
1. Destructure both `profile` and `credentials` from the result ```typescript
2. Pass `credentials` to the provider via `ExecuteOptions` export async function getProviderByModelId(
modelId: string,
**Files updated:** settingsService: SettingsService,
logPrefix?: string
- `apps/server/src/services/agent-service.ts` ): Promise<{
- `apps/server/src/services/auto-mode-service.ts` (2 locations) provider?: ClaudeCompatibleProvider;
- `apps/server/src/services/ideation-service.ts` (2 locations) resolvedModel?: string;
- `apps/server/src/providers/simple-query-service.ts` credentials?: Credentials;
- `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
<Select
value={formData.apiKeySource}
onValueChange={(value: ApiKeySource) => setFormData({ ...formData, apiKeySource: value })}
>
<SelectContent>
<SelectItem value="credentials">Use saved API key (from Settings API Keys)</SelectItem>
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
<SelectItem value="inline">Enter key for this profile only</SelectItem>
</SelectContent>
</Select>
``` ```
The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. This is used by all routes that call the Claude SDK to:
#### 2. API Keys Section (`api-keys-section.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
Added an informational note: ### Phase Model Resolution
> 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. The `getPhaseModelWithOverrides()` helper gets effective phase model config:
## User Flows ```typescript
export async function getPhaseModelWithOverrides(
phaseKey: PhaseModelKey,
settingsService: SettingsService,
projectPath?: string,
logPrefix?: string
): Promise<{
model: string;
thinkingLevel?: ThinkingLevel;
providerId?: string;
providerConfig?: ClaudeCompatibleProvider;
credentials?: Credentials;
}>;
```
### New User Flow This handles:
1. Go to Settings → API Keys 1. Project-level overrides (if projectPath provided)
2. Enter Anthropic API key and save 2. Global phase model settings
3. Go to Settings → Providers → Claude 3. Default fallback models
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
### Existing User Migration ## UI Implementation
When an existing user with an Anthropic API key (but no profiles) loads settings: ### Model Selection Dropdowns
1. System detects v4→v5 migration needed Phase model selectors (`PhaseModelSelector`) display:
2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'`
3. Sets new profile as active
4. User's existing workflow continues to work seamlessly
### Environment Variable Flow 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
For CI/CD or containerized deployments: ### Provider Icons
1. Set `ANTHROPIC_API_KEY` in environment Icons are determined by `providerType`:
2. Create profile with `apiKeySource: 'env'`
3. Profile will use the environment variable at runtime
## Backwards Compatibility - `glm` → Z logo
- `minimax` → MiniMax logo
- `openrouter` → OpenRouter logo
- Generic → OpenRouter as fallback
- Profiles without `apiKeySource` field default to `'inline'` ### Bulk Replace
- Existing profiles with inline `apiKey` continue to work unchanged
- No changes to the credentials file format The "Bulk Replace" feature allows switching all phase models to a provider at once:
- 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
The Bulk Replace button only appears when at least one provider is enabled.
## Project-Level Overrides
Projects can override global phase model settings via `phaseModelOverrides`:
```typescript
interface Project {
// ...
phaseModelOverrides?: PhaseModelConfig; // Per-phase overrides
}
```
### Storage
Project overrides are stored in `.automaker/settings.json`:
```json
{
"phaseModelOverrides": {
"enhancementModel": {
"providerId": "provider-uuid",
"model": "GLM-4.5-Air",
"thinkingLevel": "none"
}
}
}
```
### Resolution Priority
1. Project override for specific phase (if set)
2. Global phase model setting
3. Default model for phase
## Migration
### v5 → v6 Migration
The system migrated from `claudeApiProfiles` to `claudeCompatibleProviders`:
```typescript
// Old: modelMappings object
{
modelMappings: {
haiku: 'GLM-4.5-Air',
sonnet: 'GLM-4.7',
opus: 'GLM-4.7'
}
}
// New: models array with mapsToClaudeModel
{
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' },
]
}
```
The migration is automatic and preserves existing provider configurations.
## Files Changed ## Files Changed
| File | Changes | ### Types
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | | File | Changes |
| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | | ---------------------------- | -------------------------------------------------------------------- |
| `libs/types/src/index.ts` | Exported `ApiKeySource` type | | `libs/types/src/settings.ts` | `ClaudeCompatibleProvider`, `ProviderModel`, `PhaseModelEntry` types |
| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | | `libs/types/src/provider.ts` | `ExecuteOptions.claudeCompatibleProvider` field |
| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | | `libs/types/src/index.ts` | Exports for new types |
| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration |
| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | ### Server
| `apps/server/src/services/*.ts` | Updated to pass credentials |
| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | | File | Changes |
| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | | ---------------------------------------------- | -------------------------------------------------------- |
| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | | `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 |
### UI
| 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 |
## Testing ## Testing
To verify the implementation:
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`
```bash ```bash
# Build and run # Build and run
npm run build:packages npm run build:packages
@@ -373,76 +304,20 @@ npm run dev:web
npm run test:server npm run test:server
``` ```
## Per-Project Profile Override ### Test Cases
Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations. 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
### Configuration 3. **Thinking levels**: Select thinking level for provider model
4. **Bulk replace**: Switch all phases to a provider at once
In **Project Settings → Claude**, users can select: 5. **Project override**: Set per-project model override, verify it persists
6. **Provider deletion**: Delete all providers, verify empty state persists
| 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 |
| **\<Profile Name\>** | Uses that specific profile for this project only |
### Storage
The per-project setting is stored in `.automaker/settings.json`:
```json
{
"activeClaudeApiProfileId": "profile-id-here"
}
```
- `undefined` (or key absent): Use global setting
- `null`: Explicitly use Direct Anthropic API
- `"<id>"`: Use specific profile by ID
### Implementation
The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter:
```typescript
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]',
projectPath?: string // Optional: check project settings first
): Promise<ActiveClaudeApiProfileResult>;
```
When `projectPath` is provided:
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
### Scope
**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.
Affected operations when using Claude models:
- Agent chat and feature implementation
- Code analysis and suggestions
- Commit message generation
- Spec generation and sync
- Issue validation
- Backlog planning
### Use 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
## Future Enhancements ## Future Enhancements
Potential future improvements: Potential improvements:
1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources 1. **Provider validation**: Test API connection before saving
2. **Validation**: Warn if selected source has no key configured 2. **Usage tracking**: Show which phases use which provider
3. **Per-provider credentials**: Support different credential keys for different providers 3. **Cost estimation**: Display estimated costs per provider
4. **Key rotation**: Support for rotating keys without updating profiles 4. **Model capabilities**: Auto-detect supported features from provider

View File

@@ -113,11 +113,12 @@ export function resolveModelString(
return canonicalKey; return canonicalKey;
} }
// Unknown model key - use default // Unknown model key - pass through as-is (could be a provider model like GLM-4.7, MiniMax-M2.1)
console.warn( // This allows ClaudeCompatibleProvider models to work without being registered here
`[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"` 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; model: string;
/** Optional thinking level for extended thinking */ /** Optional thinking level for extended thinking */
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
/** Provider ID if using a ClaudeCompatibleProvider */
providerId?: string;
} }
/** /**
@@ -198,8 +201,23 @@ export function resolvePhaseModel(
// Handle new PhaseModelEntry object format // Handle new PhaseModelEntry object format
console.log( 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 { return {
model: resolveModelString(phaseModel.model, defaultModel), model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel, thinkingLevel: phaseModel.thinkingLevel,

View File

@@ -168,32 +168,38 @@ describe('model-resolver', () => {
}); });
}); });
describe('with unknown model keys', () => { describe('with unknown model keys (provider models)', () => {
it('should return default for unknown model key', () => { // Unknown models are now passed through unchanged to support
// ClaudeCompatibleProvider models like GLM-4.7, MiniMax-M2.1, etc.
it('should pass through unknown model key unchanged (may be provider model)', () => {
const result = resolveModelString('unknown-model'); const result = resolveModelString('unknown-model');
expect(result).toBe(DEFAULT_MODELS.claude); expect(result).toBe('unknown-model');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('passing through unchanged')
);
}); });
it('should warn about unknown model key', () => { it('should pass through provider-like model names', () => {
const glmModel = resolveModelString('GLM-4.7');
const minimaxModel = resolveModelString('MiniMax-M2.1');
expect(glmModel).toBe('GLM-4.7');
expect(minimaxModel).toBe('MiniMax-M2.1');
});
it('should not warn about unknown model keys (they are valid provider models)', () => {
resolveModelString('unknown-model'); resolveModelString('unknown-model');
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown model key')); expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-model'));
}); });
it('should use custom default for unknown model key', () => { it('should ignore custom default for unknown model key (passthrough takes precedence)', () => {
const customDefault = 'claude-opus-4-20241113'; const customDefault = 'claude-opus-4-20241113';
const result = resolveModelString('truly-unknown-model', customDefault); const result = resolveModelString('truly-unknown-model', customDefault);
expect(result).toBe(customDefault); // Unknown models pass through unchanged, default is not used
}); expect(result).toBe('truly-unknown-model');
it('should warn and show default being used', () => {
const customDefault = 'claude-custom-default';
resolveModelString('invalid-key', customDefault);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(customDefault));
}); });
}); });
@@ -202,17 +208,17 @@ describe('model-resolver', () => {
const resultUpper = resolveModelString('SONNET'); const resultUpper = resolveModelString('SONNET');
const resultLower = resolveModelString('sonnet'); const resultLower = resolveModelString('sonnet');
// Uppercase should not resolve (falls back to default) // Uppercase is passed through (could be a provider model)
expect(resultUpper).toBe(DEFAULT_MODELS.claude); expect(resultUpper).toBe('SONNET');
// Lowercase should resolve // Lowercase should resolve to Claude model
expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet); expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet);
}); });
it('should handle mixed case in claude- strings', () => { it('should handle mixed case in claude- strings', () => {
const result = resolveModelString('Claude-Sonnet-4-20250514'); const result = resolveModelString('Claude-Sonnet-4-20250514');
// Capital 'C' means it won't match 'claude-', falls back to default // Capital 'C' means it won't match 'claude-', passed through as provider model
expect(result).toBe(DEFAULT_MODELS.claude); expect(result).toBe('Claude-Sonnet-4-20250514');
}); });
}); });
@@ -220,14 +226,15 @@ describe('model-resolver', () => {
it('should handle model key with whitespace', () => { it('should handle model key with whitespace', () => {
const result = resolveModelString(' sonnet '); const result = resolveModelString(' sonnet ');
// Will not match due to whitespace, falls back to default // Will not match due to whitespace, passed through as-is (could be provider model)
expect(result).toBe(DEFAULT_MODELS.claude); expect(result).toBe(' sonnet ');
}); });
it('should handle special characters in model key', () => { it('should handle special characters in model key', () => {
const result = resolveModelString('model@123'); const result = resolveModelString('model@123');
expect(result).toBe(DEFAULT_MODELS.claude); // Passed through as-is (could be a provider model)
expect(result).toBe('model@123');
}); });
}); });
}); });
@@ -325,11 +332,11 @@ describe('model-resolver', () => {
expect(result).toBe(CLAUDE_MODEL_MAP.opus); expect(result).toBe(CLAUDE_MODEL_MAP.opus);
}); });
it('should handle fallback chain: unknown -> session -> default', () => { it('should pass through unknown model (may be provider model)', () => {
const result = getEffectiveModel('invalid', 'also-invalid', 'claude-opus-4-20241113'); const result = getEffectiveModel('GLM-4.7', 'also-unknown', 'claude-opus-4-20241113');
// Both invalid models fall back to default // Unknown models pass through unchanged (could be provider models)
expect(result).toBe('claude-opus-4-20241113'); expect(result).toBe('GLM-4.7');
}); });
it('should handle session with alias, no explicit', () => { it('should handle session with alias, no explicit', () => {
@@ -523,19 +530,21 @@ describe('model-resolver', () => {
expect(result.thinkingLevel).toBeUndefined(); expect(result.thinkingLevel).toBeUndefined();
}); });
it('should handle unknown model alias in entry', () => { it('should pass through unknown model in entry (may be provider model)', () => {
const entry: PhaseModelEntry = { model: 'unknown-model' as any }; const entry: PhaseModelEntry = { model: 'GLM-4.7' as any };
const result = resolvePhaseModel(entry); const result = resolvePhaseModel(entry);
expect(result.model).toBe(DEFAULT_MODELS.claude); // Unknown models pass through unchanged (could be provider models)
expect(result.model).toBe('GLM-4.7');
}); });
it('should use custom default for unknown model in entry', () => { it('should pass through unknown model with thinkingLevel', () => {
const entry: PhaseModelEntry = { model: 'invalid' as any, thinkingLevel: 'high' }; const entry: PhaseModelEntry = { model: 'MiniMax-M2.1' as any, thinkingLevel: 'high' };
const customDefault = 'claude-haiku-4-5-20251001'; const customDefault = 'claude-haiku-4-5-20251001';
const result = resolvePhaseModel(entry, customDefault); const result = resolvePhaseModel(entry, customDefault);
expect(result.model).toBe(customDefault); // Unknown models pass through, thinkingLevel is preserved
expect(result.model).toBe('MiniMax-M2.1');
expect(result.thinkingLevel).toBe('high'); expect(result.thinkingLevel).toBe('high');
}); });
}); });

View File

@@ -161,8 +161,14 @@ export type {
EventHookHttpAction, EventHookHttpAction,
EventHookAction, EventHookAction,
EventHook, EventHook,
// Claude API profile types // Claude-compatible provider types (new)
ApiKeySource, ApiKeySource,
ClaudeCompatibleProviderType,
ClaudeModelAlias,
ProviderModel,
ClaudeCompatibleProvider,
ClaudeCompatibleProviderTemplate,
// Claude API profile types (deprecated)
ClaudeApiProfile, ClaudeApiProfile,
ClaudeApiProfileTemplate, ClaudeApiProfileTemplate,
} from './settings.js'; } from './settings.js';
@@ -180,7 +186,9 @@ export {
getThinkingTokenBudget, getThinkingTokenBudget,
// Event hook constants // Event hook constants
EVENT_HOOK_TRIGGER_LABELS, 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, CLAUDE_API_PROFILE_TEMPLATES,
} from './settings.js'; } from './settings.js';

View File

@@ -2,7 +2,12 @@
* Shared types for AI model providers * 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'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
/** /**
@@ -213,11 +218,19 @@ export interface ExecuteOptions {
* Active Claude API profile for alternative endpoint configuration. * 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 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). * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth).
* @deprecated Use claudeCompatibleProvider instead
*/ */
claudeApiProfile?: ClaudeApiProfile; claudeApiProfile?: ClaudeApiProfile;
/** /**
* Credentials for resolving 'credentials' apiKeySource in Claude API profiles. * Claude-compatible provider for alternative endpoint configuration.
* When a profile has apiKeySource='credentials', the Anthropic key from this object is used. * 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; credentials?: Credentials;
} }

View File

@@ -102,7 +102,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; 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'; 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<string, unknown>;
}
/** /**
* ClaudeApiProfile - Configuration for a Claude-compatible API endpoint * 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 { export interface ClaudeApiProfile {
/** Unique identifier (uuid) */ /** Unique identifier (uuid) */
@@ -139,7 +219,7 @@ export interface ClaudeApiProfile {
useAuthToken?: boolean; useAuthToken?: boolean;
/** API_TIMEOUT_MS override in milliseconds */ /** API_TIMEOUT_MS override in milliseconds */
timeoutMs?: number; timeoutMs?: number;
/** Optional model name mappings */ /** Optional model name mappings (deprecated - use ClaudeCompatibleProvider.models instead) */
modelMappings?: { modelMappings?: {
/** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */ /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */
haiku?: string; haiku?: string;
@@ -152,11 +232,136 @@ export interface ClaudeApiProfile {
disableNonessentialTraffic?: boolean; 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 { export interface ClaudeApiProfileTemplate {
name: string; name: string;
baseUrl: string; baseUrl: string;
/** Default API key source for this template (user chooses when creating) */
defaultApiKeySource?: ApiKeySource; defaultApiKeySource?: ApiKeySource;
useAuthToken: boolean; useAuthToken: boolean;
timeoutMs?: number; timeoutMs?: number;
@@ -166,7 +371,9 @@ export interface ClaudeApiProfileTemplate {
apiKeyUrl?: string; apiKeyUrl?: string;
} }
/** Predefined templates for known Claude-compatible providers */ /**
* @deprecated Use CLAUDE_PROVIDER_TEMPLATES instead
*/
export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
{ {
name: 'Direct Anthropic', name: 'Direct Anthropic',
@@ -229,7 +436,6 @@ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
description: 'MiniMax M2.1 for users in China', description: 'MiniMax M2.1 for users in China',
apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', 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 * - Claude models: Use thinkingLevel for extended thinking
* - Codex models: Use reasoningEffort for reasoning intensity * - Codex models: Use reasoningEffort for reasoning intensity
* - Cursor models: Handle thinking internally * - 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 { 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) */ /** The model to use (supports Claude, Cursor, Codex, OpenCode, and dynamic provider IDs) */
model: ModelId; model: ModelId;
/** Extended thinking level (only applies to Claude models, defaults to 'none') */ /** Extended thinking level (only applies to Claude models, defaults to 'none') */
@@ -790,16 +1009,24 @@ export interface GlobalSettings {
*/ */
eventHooks?: EventHook[]; eventHooks?: EventHook[];
// Claude API Profiles Configuration // Claude-Compatible Providers Configuration
/** /**
* Claude-compatible API endpoint profiles * Claude-compatible provider configurations.
* Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. * 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[]; claudeApiProfiles?: ClaudeApiProfile[];
/** /**
* Active profile ID (null/undefined = use direct Anthropic API) * @deprecated No longer used. Models are selected per-phase via phaseModels.
* When set, the corresponding profile's settings will be used for Claude API calls * Each PhaseModelEntry can specify a providerId for provider-specific models.
*/ */
activeClaudeApiProfileId?: string | null; activeClaudeApiProfileId?: string | null;
@@ -951,12 +1178,19 @@ export interface ProjectSettings {
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */ /** Maximum concurrent agents for this project (overrides global maxConcurrency) */
maxConcurrentAgents?: number; maxConcurrentAgents?: number;
// Claude API Profile Override (per-project) // Phase Model Overrides (per-project)
/** /**
* Override the active Claude API profile for this project. * Override phase model settings for this project.
* - undefined: Use global setting (activeClaudeApiProfileId) * Any phase not specified here falls back to global phaseModels setting.
* - null: Explicitly use Direct Anthropic API (no profile) * Allows per-project customization of which models are used for each task.
* - string: Use specific profile by ID */
phaseModelOverrides?: Partial<PhaseModelConfig>;
// Deprecated Claude API Profile Override
/**
* @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; activeClaudeApiProfileId?: string | null;
} }
@@ -992,7 +1226,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
}; };
/** Current version of the global settings schema */ /** Current version of the global settings schema */
export const SETTINGS_VERSION = 5; export const SETTINGS_VERSION = 6;
/** Current version of the credentials schema */ /** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1; export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */ /** Current version of the project settings schema */
@@ -1081,6 +1315,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
skillsSources: ['user', 'project'], skillsSources: ['user', 'project'],
enableSubagents: true, enableSubagents: true,
subagentsSources: ['user', 'project'], subagentsSources: ['user', 'project'],
// New provider system
claudeCompatibleProviders: [],
// Deprecated - kept for migration
claudeApiProfiles: [], claudeApiProfiles: [],
activeClaudeApiProfileId: null, activeClaudeApiProfileId: null,
autoModeByWorktree: {}, autoModeByWorktree: {},

View File

@@ -7,6 +7,7 @@
import { secureFs } from '@automaker/platform'; import { secureFs } from '@automaker/platform';
import path from 'path'; import path from 'path';
import crypto from 'crypto';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import { mkdirSafe } from './fs-utils.js'; import { mkdirSafe } from './fs-utils.js';
@@ -99,7 +100,9 @@ export async function atomicWriteJson<T>(
): Promise<void> { ): Promise<void> {
const { indent = 2, createDirs = false, backupCount = 0 } = options; const { indent = 2, createDirs = false, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath); 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 // Create parent directories if requested
if (createDirs) { if (createDirs) {

View File

@@ -64,16 +64,17 @@ describe('atomic-writer.ts', () => {
await atomicWriteJson(filePath, data); await atomicWriteJson(filePath, data);
// Verify writeFile was called with temp file path and JSON content // Verify writeFile was called with temp file path and JSON content
// Format: .tmp.{timestamp}.{random-hex}
expect(secureFs.writeFile).toHaveBeenCalledTimes(1); expect(secureFs.writeFile).toHaveBeenCalledTimes(1);
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
expect(writeCall[0]).toMatch(/\.tmp\.\d+$/); expect(writeCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(writeCall[1]).toBe(JSON.stringify(data, null, 2)); expect(writeCall[1]).toBe(JSON.stringify(data, null, 2));
expect(writeCall[2]).toBe('utf-8'); expect(writeCall[2]).toBe('utf-8');
// Verify rename was called with temp -> target // Verify rename was called with temp -> target
expect(secureFs.rename).toHaveBeenCalledTimes(1); expect(secureFs.rename).toHaveBeenCalledTimes(1);
const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0]; const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0];
expect(renameCall[0]).toMatch(/\.tmp\.\d+$/); expect(renameCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(renameCall[1]).toBe(path.resolve(filePath)); expect(renameCall[1]).toBe(path.resolve(filePath));
}); });

15
package-lock.json generated
View File

@@ -6218,6 +6218,7 @@
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -6227,7 +6228,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -8438,6 +8439,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
@@ -11331,7 +11333,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11353,7 +11354,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11375,7 +11375,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11397,7 +11396,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11419,7 +11417,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11441,7 +11438,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11463,7 +11459,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11485,7 +11480,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11507,7 +11501,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11529,7 +11522,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11551,7 +11543,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },