mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge remote-tracking branch 'origin/main' into feature/v0.13.0rc-1768936017583-e6ni
# Conflicts: # apps/ui/src/components/views/board-view.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
|
||||||
}
|
}
|
||||||
if (profile.modelMappings?.sonnet) {
|
if (providerConfig.modelMappings.sonnet) {
|
||||||
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
|
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
|
||||||
|
}
|
||||||
|
if (providerConfig.modelMappings.opus) {
|
||||||
|
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
|
||||||
}
|
}
|
||||||
if (profile.modelMappings?.opus) {
|
|
||||||
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.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 });
|
||||||
|
|||||||
@@ -1042,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
'lm studio': 'lmstudio',
|
'lm studio': 'lmstudio',
|
||||||
lmstudio: 'lmstudio',
|
lmstudio: 'lmstudio',
|
||||||
opencode: 'opencode',
|
opencode: 'opencode',
|
||||||
'z.ai coding plan': 'z-ai',
|
'z.ai coding plan': 'zai-coding-plan',
|
||||||
'z.ai': 'z-ai',
|
'z.ai': 'z-ai',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
// Try to find a provider for the model (if it's a provider model like "GLM-4.7")
|
||||||
|
// This allows users to select provider models in the Agent Runner UI
|
||||||
|
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||||
|
let providerResolvedModel: string | undefined;
|
||||||
|
const requestedModel = model || session.model;
|
||||||
|
if (requestedModel && this.settingsService) {
|
||||||
|
const providerResult = await getProviderByModelId(
|
||||||
|
requestedModel,
|
||||||
this.settingsService,
|
this.settingsService,
|
||||||
'[AgentService]',
|
'[AgentService]'
|
||||||
effectiveWorkDir
|
|
||||||
);
|
);
|
||||||
|
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
|
||||||
|
|||||||
@@ -68,12 +68,28 @@ 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';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current branch name for a git repository
|
||||||
|
* @param projectPath - Path to the git repository
|
||||||
|
* @returns The current branch name, or null if not in a git repo or on detached HEAD
|
||||||
|
*/
|
||||||
|
async function getCurrentBranch(projectPath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
|
||||||
|
const branch = stdout.trim();
|
||||||
|
return branch || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PlanningMode type is imported from @automaker/types
|
// PlanningMode type is imported from @automaker/types
|
||||||
|
|
||||||
interface ParsedTask {
|
interface ParsedTask {
|
||||||
@@ -635,7 +651,7 @@ export class AutoModeService {
|
|||||||
iterationCount++;
|
iterationCount++;
|
||||||
try {
|
try {
|
||||||
// Count running features for THIS project/worktree only
|
// Count running features for THIS project/worktree only
|
||||||
const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName);
|
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
|
|
||||||
// Check if we have capacity for this project/worktree
|
// Check if we have capacity for this project/worktree
|
||||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||||
@@ -728,20 +744,24 @@ export class AutoModeService {
|
|||||||
/**
|
/**
|
||||||
* Get count of running features for a specific worktree
|
* Get count of running features for a specific worktree
|
||||||
* @param projectPath - The project path
|
* @param projectPath - The project path
|
||||||
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
|
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
|
||||||
*/
|
*/
|
||||||
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
private async getRunningCountForWorktree(
|
||||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
projectPath: string,
|
||||||
|
branchName: string | null
|
||||||
|
): Promise<number> {
|
||||||
|
// Get the actual primary branch name for the project
|
||||||
|
const primaryBranch = await getCurrentBranch(projectPath);
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [, feature] of this.runningFeatures) {
|
for (const [, feature] of this.runningFeatures) {
|
||||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (normalizedBranch === null) {
|
if (branchName === null) {
|
||||||
// Main worktree: match features with branchName === null OR branchName === "main"
|
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
||||||
if (
|
const isPrimaryBranch =
|
||||||
feature.projectPath === projectPath &&
|
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||||
(featureBranch === null || featureBranch === 'main')
|
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
||||||
) {
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -790,7 +810,7 @@ export class AutoModeService {
|
|||||||
// Remove from map
|
// Remove from map
|
||||||
this.autoLoopsByProject.delete(worktreeKey);
|
this.autoLoopsByProject.delete(worktreeKey);
|
||||||
|
|
||||||
return this.getRunningCountForWorktree(projectPath, branchName);
|
return await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1025,7 +1045,7 @@ export class AutoModeService {
|
|||||||
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||||
|
|
||||||
// Get current running count for this worktree
|
// Get current running count for this worktree
|
||||||
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
|
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasCapacity: currentAgents < maxAgents,
|
hasCapacity: currentAgents < maxAgents,
|
||||||
@@ -2377,13 +2397,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);
|
||||||
|
|
||||||
@@ -2405,13 +2436,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,
|
||||||
@@ -2421,8 +2445,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);
|
||||||
@@ -3017,6 +3041,10 @@ Format your response as a structured markdown document.`;
|
|||||||
// Features are stored in .automaker directory
|
// Features are stored in .automaker directory
|
||||||
const featuresDir = getFeaturesDir(projectPath);
|
const featuresDir = getFeaturesDir(projectPath);
|
||||||
|
|
||||||
|
// Get the actual primary branch name for the project (e.g., "main", "master", "develop")
|
||||||
|
// This is needed to correctly match features when branchName is null (main worktree)
|
||||||
|
const primaryBranch = await getCurrentBranch(projectPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await secureFs.readdir(featuresDir, {
|
const entries = await secureFs.readdir(featuresDir, {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
@@ -3056,17 +3084,21 @@ Format your response as a structured markdown document.`;
|
|||||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
||||||
) {
|
) {
|
||||||
// Filter by branchName:
|
// Filter by branchName:
|
||||||
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
// - If branchName is null (main worktree), include features with:
|
||||||
|
// - branchName === null, OR
|
||||||
|
// - branchName === primaryBranch (e.g., "main", "master", "develop")
|
||||||
// - If branchName is set, only include features with matching branchName
|
// - If branchName is set, only include features with matching branchName
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (branchName === null) {
|
if (branchName === null) {
|
||||||
// Main worktree: include features without branchName OR with branchName === "main"
|
// Main worktree: include features without branchName OR with branchName matching primary branch
|
||||||
// This handles both correct (null) and legacy ("main") cases
|
// This handles repos where the primary branch is named something other than "main"
|
||||||
if (featureBranch === null || featureBranch === 'main') {
|
const isPrimaryBranch =
|
||||||
|
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||||
|
if (isPrimaryBranch) {
|
||||||
pendingFeatures.push(feature);
|
pendingFeatures.push(feature);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree`
|
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3463,16 +3495,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();
|
||||||
|
|
||||||
|
// Try to find a provider for the model (if it's a provider model like "GLM-4.7")
|
||||||
|
// This allows users to select provider models in the Auto Mode / Feature execution
|
||||||
|
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||||
|
let providerResolvedModel: string | undefined;
|
||||||
|
if (finalModel && this.settingsService) {
|
||||||
|
const providerResult = await getProviderByModelId(
|
||||||
|
finalModel,
|
||||||
this.settingsService,
|
this.settingsService,
|
||||||
'[AutoMode]',
|
'[AutoMode]'
|
||||||
finalProjectPath
|
|
||||||
);
|
);
|
||||||
|
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,
|
||||||
@@ -3481,8 +3534,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
|
||||||
@@ -3788,8 +3841,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 = '';
|
||||||
@@ -3937,8 +3990,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 = '';
|
||||||
@@ -4037,8 +4090,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -1311,4 +1311,317 @@ describe('opencode-provider.ts', () => {
|
|||||||
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
|
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// parseProvidersOutput Tests
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
describe('parseProvidersOutput', () => {
|
||||||
|
// Helper function to access private method
|
||||||
|
function parseProviders(output: string) {
|
||||||
|
return (
|
||||||
|
provider as unknown as {
|
||||||
|
parseProvidersOutput: (output: string) => Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
authMethod?: 'oauth' | 'api_key';
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).parseProvidersOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Critical Fix Validation
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Critical Fix Validation', () => {
|
||||||
|
it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => {
|
||||||
|
const output = '● z.ai coding plan oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('zai-coding-plan');
|
||||||
|
expect(result[0].name).toBe('z.ai coding plan');
|
||||||
|
expect(result[0].authMethod).toBe('oauth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map "z.ai" to "z-ai" (different from coding plan)', () => {
|
||||||
|
const output = '● z.ai api';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('z-ai');
|
||||||
|
expect(result[0].name).toBe('z.ai');
|
||||||
|
expect(result[0].authMethod).toBe('api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should distinguish between "z.ai coding plan" and "z.ai"', () => {
|
||||||
|
const output = '● z.ai coding plan oauth\n● z.ai api';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('zai-coding-plan');
|
||||||
|
expect(result[0].name).toBe('z.ai coding plan');
|
||||||
|
expect(result[1].id).toBe('z-ai');
|
||||||
|
expect(result[1].name).toBe('z.ai');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Provider Name Mapping
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Provider Name Mapping', () => {
|
||||||
|
it('should map all 12 providers correctly', () => {
|
||||||
|
const output = `● anthropic oauth
|
||||||
|
● github copilot oauth
|
||||||
|
● google api
|
||||||
|
● openai api
|
||||||
|
● openrouter api
|
||||||
|
● azure api
|
||||||
|
● amazon bedrock oauth
|
||||||
|
● ollama api
|
||||||
|
● lm studio api
|
||||||
|
● opencode oauth
|
||||||
|
● z.ai coding plan oauth
|
||||||
|
● z.ai api`;
|
||||||
|
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(12);
|
||||||
|
expect(result.map((p) => p.id)).toEqual([
|
||||||
|
'anthropic',
|
||||||
|
'github-copilot',
|
||||||
|
'google',
|
||||||
|
'openai',
|
||||||
|
'openrouter',
|
||||||
|
'azure',
|
||||||
|
'amazon-bedrock',
|
||||||
|
'ollama',
|
||||||
|
'lmstudio',
|
||||||
|
'opencode',
|
||||||
|
'zai-coding-plan',
|
||||||
|
'z-ai',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-insensitive provider names and preserve original casing', () => {
|
||||||
|
const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
expect(result[0].name).toBe('Anthropic'); // Preserves casing
|
||||||
|
expect(result[1].id).toBe('openai');
|
||||||
|
expect(result[1].name).toBe('OPENAI'); // Preserves casing
|
||||||
|
expect(result[2].id).toBe('github-copilot');
|
||||||
|
expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-word provider names with spaces', () => {
|
||||||
|
const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].id).toBe('amazon-bedrock');
|
||||||
|
expect(result[0].name).toBe('Amazon Bedrock');
|
||||||
|
expect(result[1].id).toBe('lmstudio');
|
||||||
|
expect(result[1].name).toBe('LM Studio');
|
||||||
|
expect(result[2].id).toBe('github-copilot');
|
||||||
|
expect(result[2].name).toBe('GitHub Copilot');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Duplicate Aliases
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Duplicate Aliases', () => {
|
||||||
|
it('should map provider aliases to the same ID', () => {
|
||||||
|
// Test copilot variants
|
||||||
|
const copilot1 = parseProviders('● copilot oauth');
|
||||||
|
const copilot2 = parseProviders('● github copilot oauth');
|
||||||
|
expect(copilot1[0].id).toBe('github-copilot');
|
||||||
|
expect(copilot2[0].id).toBe('github-copilot');
|
||||||
|
|
||||||
|
// Test bedrock variants
|
||||||
|
const bedrock1 = parseProviders('● bedrock oauth');
|
||||||
|
const bedrock2 = parseProviders('● amazon bedrock oauth');
|
||||||
|
expect(bedrock1[0].id).toBe('amazon-bedrock');
|
||||||
|
expect(bedrock2[0].id).toBe('amazon-bedrock');
|
||||||
|
|
||||||
|
// Test lmstudio variants
|
||||||
|
const lm1 = parseProviders('● lmstudio api');
|
||||||
|
const lm2 = parseProviders('● lm studio api');
|
||||||
|
expect(lm1[0].id).toBe('lmstudio');
|
||||||
|
expect(lm2[0].id).toBe('lmstudio');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Authentication Methods
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Authentication Methods', () => {
|
||||||
|
it('should detect oauth and api_key auth methods', () => {
|
||||||
|
const output = '● anthropic oauth\n● openai api\n● google api_key';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].authMethod).toBe('oauth');
|
||||||
|
expect(result[1].authMethod).toBe('api_key');
|
||||||
|
expect(result[2].authMethod).toBe('api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set authenticated to true and handle case-insensitive auth methods', () => {
|
||||||
|
const output = '● anthropic OAuth\n● openai API';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].authenticated).toBe(true);
|
||||||
|
expect(result[0].authMethod).toBe('oauth');
|
||||||
|
expect(result[1].authenticated).toBe(true);
|
||||||
|
expect(result[1].authMethod).toBe('api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined authMethod for unknown auth types', () => {
|
||||||
|
const output = '● anthropic unknown-auth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].authenticated).toBe(true);
|
||||||
|
expect(result[0].authMethod).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// ANSI Escape Sequences
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('ANSI Escape Sequences', () => {
|
||||||
|
it('should strip ANSI color codes from output', () => {
|
||||||
|
const output = '\x1b[32m● anthropic oauth\x1b[0m';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
expect(result[0].name).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex ANSI sequences and codes in provider names', () => {
|
||||||
|
const output =
|
||||||
|
'\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('github-copilot');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should return empty array for empty output or no ● symbols', () => {
|
||||||
|
expect(parseProviders('')).toEqual([]);
|
||||||
|
expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]);
|
||||||
|
expect(parseProviders('No authenticated providers')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip malformed lines with ● but insufficient content', () => {
|
||||||
|
const output = '●\n● \n● anthropic\n● openai api';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
// Only the last line has both provider name and auth method
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback for unknown providers (spaces to hyphens)', () => {
|
||||||
|
const output = '● unknown provider name oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].id).toBe('unknown-provider-name');
|
||||||
|
expect(result[0].name).toBe('unknown provider name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle extra whitespace and mixed case', () => {
|
||||||
|
const output = '● AnThRoPiC oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
expect(result[0].name).toBe('AnThRoPiC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple ● symbols on same line', () => {
|
||||||
|
const output = '● ● anthropic oauth';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different newline formats and trailing newlines', () => {
|
||||||
|
const outputUnix = '● anthropic oauth\n● openai api';
|
||||||
|
const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n';
|
||||||
|
|
||||||
|
const resultUnix = parseProviders(outputUnix);
|
||||||
|
const resultWindows = parseProviders(outputWindows);
|
||||||
|
|
||||||
|
expect(resultUnix).toHaveLength(2);
|
||||||
|
expect(resultWindows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle provider names with numbers and special characters', () => {
|
||||||
|
const output = '● gpt-4o api';
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result[0].id).toBe('gpt-4o');
|
||||||
|
expect(result[0].name).toBe('gpt-4o');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Real-world CLI Output
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
describe('Real-world CLI Output', () => {
|
||||||
|
it('should parse CLI output with box drawing characters and decorations', () => {
|
||||||
|
const output = `┌─────────────────────────────────────────────────┐
|
||||||
|
│ Authenticated Providers │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
● anthropic oauth
|
||||||
|
● openai api
|
||||||
|
└─────────────────────────────────────────────────┘`;
|
||||||
|
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
expect(result[1].id).toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse output with ANSI colors and box characters', () => {
|
||||||
|
const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m
|
||||||
|
\x1b[1m│ Authenticated Providers │\x1b[0m
|
||||||
|
\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m
|
||||||
|
\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m
|
||||||
|
\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m
|
||||||
|
\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`;
|
||||||
|
|
||||||
|
const result = parseProviders(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('anthropic');
|
||||||
|
expect(result[1].id).toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle "no authenticated providers" message', () => {
|
||||||
|
const output = `┌─────────────────────────────────────────────────┐
|
||||||
|
│ No authenticated providers found │
|
||||||
|
└─────────────────────────────────────────────────┘`;
|
||||||
|
|
||||||
|
const result = parseProviders(output);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
setCardBorderOpacity,
|
setCardBorderOpacity,
|
||||||
setHideScrollbar,
|
setHideScrollbar,
|
||||||
clearBoardBackground,
|
clearBoardBackground,
|
||||||
|
persistSettings,
|
||||||
|
getCurrentSettings,
|
||||||
} = useBoardBackgroundSettings();
|
} = useBoardBackgroundSettings();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -55,12 +57,31 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
const backgroundSettings =
|
const backgroundSettings =
|
||||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||||
|
|
||||||
const cardOpacity = backgroundSettings.cardOpacity;
|
// Local state for sliders during dragging (avoids store updates during drag)
|
||||||
const columnOpacity = backgroundSettings.columnOpacity;
|
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
|
||||||
|
const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
|
||||||
|
const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
|
||||||
|
backgroundSettings.cardBorderOpacity
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
// Sync local state with store when not dragging (e.g., on modal open or external changes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) {
|
||||||
|
setLocalCardOpacity(backgroundSettings.cardOpacity);
|
||||||
|
setLocalColumnOpacity(backgroundSettings.columnOpacity);
|
||||||
|
setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isDragging,
|
||||||
|
backgroundSettings.cardOpacity,
|
||||||
|
backgroundSettings.columnOpacity,
|
||||||
|
backgroundSettings.cardBorderOpacity,
|
||||||
|
]);
|
||||||
|
|
||||||
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||||
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
|
||||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||||
const imageVersion = backgroundSettings.imageVersion;
|
const imageVersion = backgroundSettings.imageVersion;
|
||||||
|
|
||||||
@@ -198,21 +219,40 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
}
|
}
|
||||||
}, [currentProject, clearBoardBackground]);
|
}, [currentProject, clearBoardBackground]);
|
||||||
|
|
||||||
// Live update opacity when sliders change (with persistence)
|
// Live update local state during drag (modal-only, no store update)
|
||||||
const handleCardOpacityChange = useCallback(
|
const handleCardOpacityChange = useCallback((value: number[]) => {
|
||||||
async (value: number[]) => {
|
setIsDragging(true);
|
||||||
|
setLocalCardOpacity(value[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update store and persist when slider is released
|
||||||
|
const handleCardOpacityCommit = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
await setCardOpacity(currentProject.path, value[0]);
|
setIsDragging(false);
|
||||||
|
setCardOpacity(currentProject.path, value[0]);
|
||||||
|
const current = getCurrentSettings(currentProject.path);
|
||||||
|
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
||||||
},
|
},
|
||||||
[currentProject, setCardOpacity]
|
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleColumnOpacityChange = useCallback(
|
// Live update local state during drag (modal-only, no store update)
|
||||||
async (value: number[]) => {
|
const handleColumnOpacityChange = useCallback((value: number[]) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setLocalColumnOpacity(value[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update store and persist when slider is released
|
||||||
|
const handleColumnOpacityCommit = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
await setColumnOpacity(currentProject.path, value[0]);
|
setIsDragging(false);
|
||||||
|
setColumnOpacity(currentProject.path, value[0]);
|
||||||
|
const current = getCurrentSettings(currentProject.path);
|
||||||
|
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
||||||
},
|
},
|
||||||
[currentProject, setColumnOpacity]
|
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleColumnBorderToggle = useCallback(
|
const handleColumnBorderToggle = useCallback(
|
||||||
@@ -239,12 +279,22 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
[currentProject, setCardBorderEnabled]
|
[currentProject, setCardBorderEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCardBorderOpacityChange = useCallback(
|
// Live update local state during drag (modal-only, no store update)
|
||||||
async (value: number[]) => {
|
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setLocalCardBorderOpacity(value[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update store and persist when slider is released
|
||||||
|
const handleCardBorderOpacityCommit = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
await setCardBorderOpacity(currentProject.path, value[0]);
|
setIsDragging(false);
|
||||||
|
setCardBorderOpacity(currentProject.path, value[0]);
|
||||||
|
const current = getCurrentSettings(currentProject.path);
|
||||||
|
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
||||||
},
|
},
|
||||||
[currentProject, setCardBorderOpacity]
|
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHideScrollbarToggle = useCallback(
|
const handleHideScrollbarToggle = useCallback(
|
||||||
@@ -378,11 +428,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Card Opacity</Label>
|
<Label>Card Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[cardOpacity]}
|
value={[localCardOpacity]}
|
||||||
onValueChange={handleCardOpacityChange}
|
onValueChange={handleCardOpacityChange}
|
||||||
|
onValueCommit={handleCardOpacityCommit}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -393,11 +444,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Column Opacity</Label>
|
<Label>Column Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[columnOpacity]}
|
value={[localColumnOpacity]}
|
||||||
onValueChange={handleColumnOpacityChange}
|
onValueChange={handleColumnOpacityChange}
|
||||||
|
onValueCommit={handleColumnOpacityCommit}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -446,11 +498,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Card Border Opacity</Label>
|
<Label>Card Border Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[cardBorderOpacity]}
|
value={[localCardBorderOpacity]}
|
||||||
onValueChange={handleCardBorderOpacityChange}
|
onValueChange={handleCardBorderOpacityChange}
|
||||||
|
onValueCommit={handleCardBorderOpacityCommit}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -636,11 +636,7 @@ export function BoardView() {
|
|||||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update local Zustand state
|
// Invalidate React Query cache to refetch features with server-updated values
|
||||||
featureIds.forEach((featureId) => {
|
|
||||||
updateFeature(featureId, finalUpdates);
|
|
||||||
});
|
|
||||||
// Invalidate React Query cache to ensure features are refetched with updated data
|
|
||||||
loadFeatures();
|
loadFeatures();
|
||||||
toast.success(`Updated ${result.updatedCount} features`);
|
toast.success(`Updated ${result.updatedCount} features`);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
@@ -657,13 +653,12 @@ export function BoardView() {
|
|||||||
[
|
[
|
||||||
currentProject,
|
currentProject,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
updateFeature,
|
loadFeatures,
|
||||||
exitSelectionMode,
|
exitSelectionMode,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
addAndSelectWorktree,
|
addAndSelectWorktree,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
setWorktreeRefreshKey,
|
setWorktreeRefreshKey,
|
||||||
loadFeatures,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -786,10 +781,8 @@ export function BoardView() {
|
|||||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update local state for all features
|
// Invalidate React Query cache to refetch features with server-updated values
|
||||||
featureIds.forEach((featureId) => {
|
loadFeatures();
|
||||||
updateFeature(featureId, updates);
|
|
||||||
});
|
|
||||||
toast.success(`Verified ${result.updatedCount} features`);
|
toast.success(`Verified ${result.updatedCount} features`);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
} else {
|
} else {
|
||||||
@@ -801,7 +794,7 @@ export function BoardView() {
|
|||||||
logger.error('Bulk verify failed:', error);
|
logger.error('Bulk verify failed:', error);
|
||||||
toast.error('Failed to verify features');
|
toast.error('Failed to verify features');
|
||||||
}
|
}
|
||||||
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
|
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
||||||
|
|
||||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||||
const handleAddressPRComments = useCallback(
|
const handleAddressPRComments = useCallback(
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ export function BoardHeader({
|
|||||||
onConcurrencyChange={onConcurrencyChange}
|
onConcurrencyChange={onConcurrencyChange}
|
||||||
isAutoModeRunning={isAutoModeRunning}
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
onAutoModeToggle={onAutoModeToggle}
|
onAutoModeToggle={onAutoModeToggle}
|
||||||
onOpenAutoModeSettings={() => {}}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
|
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||||
onOpenPlanDialog={onOpenPlanDialog}
|
onOpenPlanDialog={onOpenPlanDialog}
|
||||||
showClaudeUsage={showClaudeUsage}
|
showClaudeUsage={showClaudeUsage}
|
||||||
showCodexUsage={showCodexUsage}
|
showCodexUsage={showCodexUsage}
|
||||||
|
|||||||
@@ -180,8 +180,10 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
'kanban-card-content h-full relative',
|
'kanban-card-content h-full relative',
|
||||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
|
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
!reduceEffects &&
|
!reduceEffects &&
|
||||||
|
!isCurrentAutoTask &&
|
||||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function MassEditDialog({
|
|||||||
// Field values
|
// Field values
|
||||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||||
|
const [providerId, setProviderId] = useState<string | undefined>(undefined);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
const [priority, setPriority] = useState(2);
|
const [priority, setPriority] = useState(2);
|
||||||
@@ -182,6 +183,7 @@ export function MassEditDialog({
|
|||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||||
|
setProviderId(undefined); // Features don't store providerId, but we track it after selection
|
||||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||||
@@ -254,10 +256,11 @@ export function MassEditDialog({
|
|||||||
Select a specific model configuration
|
Select a specific model configuration
|
||||||
</p>
|
</p>
|
||||||
<PhaseModelSelector
|
<PhaseModelSelector
|
||||||
value={{ model, thinkingLevel }}
|
value={{ model, thinkingLevel, providerId }}
|
||||||
onChange={(entry: PhaseModelEntry) => {
|
onChange={(entry: PhaseModelEntry) => {
|
||||||
setModel(entry.model as ModelAlias);
|
setModel(entry.model as ModelAlias);
|
||||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||||
|
setProviderId(entry.providerId);
|
||||||
// Auto-enable model and thinking level for apply state
|
// Auto-enable model and thinking level for apply state
|
||||||
setApplyState((prev) => ({
|
setApplyState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
HeaderActionsPanel,
|
HeaderActionsPanel,
|
||||||
HeaderActionsPanelTrigger,
|
HeaderActionsPanelTrigger,
|
||||||
} from '@/components/ui/header-actions-panel';
|
} from '@/components/ui/header-actions-panel';
|
||||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MobileUsageBar } from './mobile-usage-bar';
|
import { MobileUsageBar } from './mobile-usage-bar';
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ interface HeaderMobileMenuProps {
|
|||||||
// Auto mode
|
// Auto mode
|
||||||
isAutoModeRunning: boolean;
|
isAutoModeRunning: boolean;
|
||||||
onAutoModeToggle: (enabled: boolean) => void;
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
onOpenAutoModeSettings: () => void;
|
skipVerificationInAutoMode: boolean;
|
||||||
|
onSkipVerificationChange: (value: boolean) => void;
|
||||||
// Plan button
|
// Plan button
|
||||||
onOpenPlanDialog: () => void;
|
onOpenPlanDialog: () => void;
|
||||||
// Usage bar visibility
|
// Usage bar visibility
|
||||||
@@ -41,7 +42,8 @@ export function HeaderMobileMenu({
|
|||||||
onConcurrencyChange,
|
onConcurrencyChange,
|
||||||
isAutoModeRunning,
|
isAutoModeRunning,
|
||||||
onAutoModeToggle,
|
onAutoModeToggle,
|
||||||
onOpenAutoModeSettings,
|
skipVerificationInAutoMode,
|
||||||
|
onSkipVerificationChange,
|
||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
showClaudeUsage,
|
showClaudeUsage,
|
||||||
showCodexUsage,
|
showCodexUsage,
|
||||||
@@ -66,9 +68,11 @@ export function HeaderMobileMenu({
|
|||||||
Controls
|
Controls
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Auto Mode Section */}
|
||||||
|
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||||
{/* Auto Mode Toggle */}
|
{/* Auto Mode Toggle */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||||
data-testid="mobile-auto-mode-toggle-container"
|
data-testid="mobile-auto-mode-toggle-container"
|
||||||
>
|
>
|
||||||
@@ -80,15 +84,7 @@ export function HeaderMobileMenu({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">Auto Mode</span>
|
<span className="text-sm font-medium">Auto Mode</span>
|
||||||
<span
|
|
||||||
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
|
||||||
data-testid="mobile-auto-mode-max-concurrency"
|
|
||||||
title="Max concurrent agents"
|
|
||||||
>
|
|
||||||
{maxConcurrency}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
<Switch
|
||||||
id="mobile-auto-mode-toggle"
|
id="mobile-auto-mode-toggle"
|
||||||
checked={isAutoModeRunning}
|
checked={isAutoModeRunning}
|
||||||
@@ -96,17 +92,51 @@ export function HeaderMobileMenu({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
data-testid="mobile-auto-mode-toggle"
|
data-testid="mobile-auto-mode-toggle"
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
{/* Skip Verification Toggle */}
|
||||||
onOpenAutoModeSettings();
|
<div
|
||||||
}}
|
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
|
||||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
|
||||||
title="Auto Mode Settings"
|
data-testid="mobile-skip-verification-toggle-container"
|
||||||
data-testid="mobile-auto-mode-settings-button"
|
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
<FastForward className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="mobile-skip-verification-toggle"
|
||||||
|
checked={skipVerificationInAutoMode}
|
||||||
|
onCheckedChange={onSkipVerificationChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid="mobile-skip-verification-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Concurrency Control */}
|
||||||
|
<div
|
||||||
|
className="p-3 pl-9 border-t border-border/30"
|
||||||
|
data-testid="mobile-concurrency-control"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Max Agents</span>
|
||||||
|
<span
|
||||||
|
className="text-sm text-muted-foreground ml-auto"
|
||||||
|
data-testid="mobile-concurrency-value"
|
||||||
|
>
|
||||||
|
{runningAgentsCount}/{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[maxConcurrency]}
|
||||||
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
data-testid="mobile-concurrency-slider"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,32 +159,6 @@ export function HeaderMobileMenu({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Concurrency Control */}
|
|
||||||
<div
|
|
||||||
className="p-3 rounded-lg border border-border/50"
|
|
||||||
data-testid="mobile-concurrency-control"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Max Agents</span>
|
|
||||||
<span
|
|
||||||
className="text-sm text-muted-foreground ml-auto"
|
|
||||||
data-testid="mobile-concurrency-value"
|
|
||||||
>
|
|
||||||
{runningAgentsCount}/{maxConcurrency}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[maxConcurrency]}
|
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
className="w-full"
|
|
||||||
data-testid="mobile-concurrency-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Button */}
|
{/* Plan Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -487,7 +487,15 @@ export function useBoardActions({
|
|||||||
const handleStartImplementation = useCallback(
|
const handleStartImplementation = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
// Check capacity for the feature's specific worktree, not the current view
|
// Check capacity for the feature's specific worktree, not the current view
|
||||||
const featureBranchName = feature.branchName ?? null;
|
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||||
|
// treat it as null (main worktree) to match how running tasks are stored
|
||||||
|
const rawBranchName = feature.branchName ?? null;
|
||||||
|
const featureBranchName =
|
||||||
|
currentProject?.path &&
|
||||||
|
rawBranchName &&
|
||||||
|
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||||
|
? null
|
||||||
|
: rawBranchName;
|
||||||
const featureWorktreeState = currentProject
|
const featureWorktreeState = currentProject
|
||||||
? getAutoModeState(currentProject.id, featureBranchName)
|
? getAutoModeState(currentProject.id, featureBranchName)
|
||||||
: null;
|
: null;
|
||||||
@@ -567,6 +575,7 @@ export function useBoardActions({
|
|||||||
handleRunFeature,
|
handleRunFeature,
|
||||||
currentProject,
|
currentProject,
|
||||||
getAutoModeState,
|
getAutoModeState,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -128,15 +128,22 @@ export function useBoardDragDrop({
|
|||||||
const targetBranch = worktreeData.branch;
|
const targetBranch = worktreeData.branch;
|
||||||
const currentBranch = draggedFeature.branchName;
|
const currentBranch = draggedFeature.branchName;
|
||||||
|
|
||||||
|
// For main worktree, set branchName to null to indicate it should use main
|
||||||
|
// (must use null not undefined so it serializes to JSON for the API call)
|
||||||
|
// For other worktrees, set branchName to the target branch
|
||||||
|
const newBranchName = worktreeData.isMain ? null : targetBranch;
|
||||||
|
|
||||||
// If already on the same branch, nothing to do
|
// If already on the same branch, nothing to do
|
||||||
if (currentBranch === targetBranch) {
|
// For main worktree: feature with null/undefined branchName is already on main
|
||||||
|
// For other worktrees: compare branch names directly
|
||||||
|
const isAlreadyOnTarget = worktreeData.isMain
|
||||||
|
? !currentBranch // null or undefined means already on main
|
||||||
|
: currentBranch === targetBranch;
|
||||||
|
|
||||||
|
if (isAlreadyOnTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For main worktree, set branchName to undefined/null to indicate it should use main
|
|
||||||
// For other worktrees, set branchName to the target branch
|
|
||||||
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
|
||||||
|
|
||||||
// Update feature's branchName
|
// Update feature's branchName
|
||||||
updateFeature(featureId, { branchName: newBranchName });
|
updateFeature(featureId, { branchName: newBranchName });
|
||||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
|||||||
|
|
||||||
// Match by branchName only (worktreePath is no longer stored)
|
// Match by branchName only (worktreePath is no longer stored)
|
||||||
if (feature.branchName) {
|
if (feature.branchName) {
|
||||||
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
// Check if branch names match - this handles both main worktree (any primary branch name)
|
||||||
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
// and feature worktrees
|
||||||
if (worktree.isMain && feature.branchName === 'main') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return worktree.branch === feature.branchName;
|
return worktree.branch === feature.branchName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,12 +146,28 @@ export function ModelDefaultsSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
Reset to Defaults
|
Reset to Defaults
|
||||||
</Button>
|
</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">
|
||||||
|
|||||||
@@ -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,93 @@ 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Check ClaudeCompatibleProvider models by model ID only (when providerId is not set)
|
||||||
|
// This handles cases where features store model ID but not providerId
|
||||||
|
for (const provider of enabledProviders) {
|
||||||
|
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 +1025,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 +1856,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 */}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function ClaudeSettingsTab() {
|
|||||||
onRefresh={handleRefreshClaudeCli}
|
onRefresh={handleRefreshClaudeCli}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* API Profiles for Claude-compatible endpoints */}
|
{/* Claude-compatible providers */}
|
||||||
<ApiProfilesSection />
|
<ApiProfilesSection />
|
||||||
|
|
||||||
<ClaudeMdSettings
|
<ClaudeMdSettings
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -67,9 +67,25 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No-op fallback for HMR transitions when context temporarily becomes unavailable
|
||||||
|
const hmrFallback: FileBrowserContextValue = {
|
||||||
|
openFileBrowser: async () => {
|
||||||
|
console.warn('[HMR] FileBrowserContext not available, returning null');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function useFileBrowser() {
|
export function useFileBrowser() {
|
||||||
const context = useContext(FileBrowserContext);
|
const context = useContext(FileBrowserContext);
|
||||||
|
// During HMR, the context can temporarily be null as modules reload.
|
||||||
|
// Instead of crashing the app, return a safe no-op fallback that will
|
||||||
|
// be replaced once the provider re-mounts.
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
if (import.meta.hot) {
|
||||||
|
// In development with HMR active, gracefully degrade
|
||||||
|
return hmrFallback;
|
||||||
|
}
|
||||||
|
// In production, this indicates a real bug - throw to help debug
|
||||||
throw new Error('useFileBrowser must be used within FileBrowserProvider');
|
throw new Error('useFileBrowser must be used within FileBrowserProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (settings: Record<string, unknown>) => {
|
mutationFn: async (settings: Record<string, unknown>) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
if (!api.settings) {
|
||||||
|
throw new Error('Settings API not available');
|
||||||
|
}
|
||||||
// Use updateGlobal for partial updates
|
// Use updateGlobal for partial updates
|
||||||
const result = await api.settings.updateGlobal(settings);
|
const result = await api.settings.updateGlobal(settings);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -66,33 +69,43 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
|||||||
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
||||||
* @returns Mutation for updating project settings
|
* @returns Mutation for updating project settings
|
||||||
*/
|
*/
|
||||||
|
interface ProjectSettingsWithPath {
|
||||||
|
projectPath: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateProjectSettings(projectPath?: string) {
|
export function useUpdateProjectSettings(projectPath?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (
|
mutationFn: async (variables: Record<string, unknown> | ProjectSettingsWithPath) => {
|
||||||
variables:
|
|
||||||
| Record<string, unknown>
|
|
||||||
| { projectPath: string; settings: Record<string, unknown> }
|
|
||||||
) => {
|
|
||||||
// Support both call patterns:
|
// Support both call patterns:
|
||||||
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
||||||
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
||||||
let path: string;
|
let path: string;
|
||||||
let settings: Record<string, unknown>;
|
let settings: Record<string, unknown>;
|
||||||
|
|
||||||
if ('projectPath' in variables && 'settings' in variables) {
|
if (
|
||||||
|
typeof variables === 'object' &&
|
||||||
|
'projectPath' in variables &&
|
||||||
|
'settings' in variables &&
|
||||||
|
typeof variables.projectPath === 'string' &&
|
||||||
|
typeof variables.settings === 'object'
|
||||||
|
) {
|
||||||
path = variables.projectPath;
|
path = variables.projectPath;
|
||||||
settings = variables.settings;
|
settings = variables.settings as Record<string, unknown>;
|
||||||
} else if (projectPath) {
|
} else if (projectPath) {
|
||||||
path = projectPath;
|
path = projectPath;
|
||||||
settings = variables;
|
settings = variables as Record<string, unknown>;
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Project path is required');
|
throw new Error('Project path is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const result = await api.settings.setProject(path, settings);
|
if (!api.settings) {
|
||||||
|
throw new Error('Settings API not available');
|
||||||
|
}
|
||||||
|
const result = await api.settings.updateProject(path, settings);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to update project settings');
|
throw new Error(result.error || 'Failed to update project settings');
|
||||||
}
|
}
|
||||||
@@ -122,9 +135,12 @@ export function useSaveCredentials() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (credentials: Record<string, string>) => {
|
mutationFn: async (credentials: { anthropic?: string; google?: string; openai?: string }) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const result = await api.settings.setCredentials(credentials);
|
if (!api.settings) {
|
||||||
|
throw new Error('Settings API not available');
|
||||||
|
}
|
||||||
|
const result = await api.settings.updateCredentials({ apiKeys: credentials });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to save credentials');
|
throw new Error(result.error || 'Failed to save credentials');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
getWorktreeKey,
|
getWorktreeKey,
|
||||||
getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
autoModeByWorktree: state.autoModeByWorktree,
|
autoModeByWorktree: state.autoModeByWorktree,
|
||||||
@@ -90,6 +91,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
getWorktreeKey: state.getWorktreeKey,
|
getWorktreeKey: state.getWorktreeKey,
|
||||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||||
|
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -197,9 +199,21 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract branchName from event, defaulting to null (main worktree)
|
// Extract branchName from event, defaulting to null (main worktree)
|
||||||
const eventBranchName: string | null =
|
const rawEventBranchName: string | null =
|
||||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||||
|
|
||||||
|
// Get projectPath for worktree lookup
|
||||||
|
const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
|
||||||
|
|
||||||
|
// Normalize branchName: convert primary worktree branch to null for consistent key lookup
|
||||||
|
// This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
|
||||||
|
const eventBranchName: string | null =
|
||||||
|
eventProjectPath &&
|
||||||
|
rawEventBranchName &&
|
||||||
|
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
|
||||||
|
? null
|
||||||
|
: rawEventBranchName;
|
||||||
|
|
||||||
// Skip event if we couldn't determine the project
|
// Skip event if we couldn't determine the project
|
||||||
if (!eventProjectId) {
|
if (!eventProjectId) {
|
||||||
logger.warn('Could not determine project for event:', event);
|
logger.warn('Could not determine project for event:', event);
|
||||||
@@ -493,6 +507,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
// Start auto mode - calls backend to start the auto loop for this worktree
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useUpdateProjectSettings } from '@/hooks/mutations';
|
|||||||
/**
|
/**
|
||||||
* Hook for managing board background settings with automatic persistence to server.
|
* Hook for managing board background settings with automatic persistence to server.
|
||||||
* Uses React Query mutation for server persistence with automatic error handling.
|
* Uses React Query mutation for server persistence with automatic error handling.
|
||||||
|
*
|
||||||
|
* For sliders, the modal uses local state during dragging and calls:
|
||||||
|
* - setCardOpacity/setColumnOpacity/setCardBorderOpacity to update store on commit
|
||||||
|
* - persistSettings directly to save to server on commit
|
||||||
*/
|
*/
|
||||||
export function useBoardBackgroundSettings() {
|
export function useBoardBackgroundSettings() {
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
@@ -65,22 +69,20 @@ export function useBoardBackgroundSettings() {
|
|||||||
[store, persistSettings, getCurrentSettings]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update store (called on slider commit to update the board view)
|
||||||
const setCardOpacity = useCallback(
|
const setCardOpacity = useCallback(
|
||||||
async (projectPath: string, opacity: number) => {
|
(projectPath: string, opacity: number) => {
|
||||||
const current = getCurrentSettings(projectPath);
|
|
||||||
store.setCardOpacity(projectPath, opacity);
|
store.setCardOpacity(projectPath, opacity);
|
||||||
await persistSettings(projectPath, { ...current, cardOpacity: opacity });
|
|
||||||
},
|
},
|
||||||
[store, persistSettings, getCurrentSettings]
|
[store]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update store (called on slider commit to update the board view)
|
||||||
const setColumnOpacity = useCallback(
|
const setColumnOpacity = useCallback(
|
||||||
async (projectPath: string, opacity: number) => {
|
(projectPath: string, opacity: number) => {
|
||||||
const current = getCurrentSettings(projectPath);
|
|
||||||
store.setColumnOpacity(projectPath, opacity);
|
store.setColumnOpacity(projectPath, opacity);
|
||||||
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
|
|
||||||
},
|
},
|
||||||
[store, persistSettings, getCurrentSettings]
|
[store]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setColumnBorderEnabled = useCallback(
|
const setColumnBorderEnabled = useCallback(
|
||||||
@@ -119,16 +121,12 @@ export function useBoardBackgroundSettings() {
|
|||||||
[store, persistSettings, getCurrentSettings]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update store (called on slider commit to update the board view)
|
||||||
const setCardBorderOpacity = useCallback(
|
const setCardBorderOpacity = useCallback(
|
||||||
async (projectPath: string, opacity: number) => {
|
(projectPath: string, opacity: number) => {
|
||||||
const current = getCurrentSettings(projectPath);
|
|
||||||
store.setCardBorderOpacity(projectPath, opacity);
|
store.setCardBorderOpacity(projectPath, opacity);
|
||||||
await persistSettings(projectPath, {
|
|
||||||
...current,
|
|
||||||
cardBorderOpacity: opacity,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[store, persistSettings, getCurrentSettings]
|
[store]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setHideScrollbar = useCallback(
|
const setHideScrollbar = useCallback(
|
||||||
@@ -170,5 +168,6 @@ export function useBoardBackgroundSettings() {
|
|||||||
setHideScrollbar,
|
setHideScrollbar,
|
||||||
clearBoardBackground,
|
clearBoardBackground,
|
||||||
getCurrentSettings,
|
getCurrentSettings,
|
||||||
|
persistSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -895,12 +895,15 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
||||||
|
const isCompact = useIsCompact();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
{SHOW_QUERY_DEVTOOLS ? (
|
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
||||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||||
) : null}
|
) : null}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
| File | Changes |
|
| File | Changes |
|
||||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
| ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template |
|
| `libs/types/src/settings.ts` | `ClaudeCompatibleProvider`, `ProviderModel`, `PhaseModelEntry` types |
|
||||||
| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` |
|
| `libs/types/src/provider.ts` | `ExecuteOptions.claudeCompatibleProvider` field |
|
||||||
| `libs/types/src/index.ts` | Exported `ApiKeySource` type |
|
| `libs/types/src/index.ts` | Exports for new types |
|
||||||
| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources |
|
|
||||||
| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials |
|
### Server
|
||||||
| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration |
|
|
||||||
| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough |
|
| File | Changes |
|
||||||
| `apps/server/src/services/*.ts` | Updated to pass credentials |
|
| ---------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) |
|
| `apps/server/src/providers/claude-provider.ts` | Provider config handling, buildEnv updates |
|
||||||
| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector |
|
| `apps/server/src/lib/settings-helpers.ts` | `getProviderByModelId()`, `getPhaseModelWithOverrides()` |
|
||||||
| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note |
|
| `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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,8 +167,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';
|
||||||
@@ -186,7 +192,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
178
libs/utils/src/string-utils.ts
Normal file
178
libs/utils/src/string-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* String utility functions for common text operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string to a maximum length, adding an ellipsis if truncated
|
||||||
|
* @param str - The string to truncate
|
||||||
|
* @param maxLength - Maximum length of the result (including ellipsis)
|
||||||
|
* @param ellipsis - The ellipsis string to use (default: '...')
|
||||||
|
* @returns The truncated string
|
||||||
|
*/
|
||||||
|
export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
|
||||||
|
if (maxLength < ellipsis.length) {
|
||||||
|
throw new Error(
|
||||||
|
`maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.length <= maxLength) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
|
||||||
|
* @param str - The string to convert
|
||||||
|
* @returns The kebab-case string
|
||||||
|
*/
|
||||||
|
export function toKebabCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
|
||||||
|
.replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
|
||||||
|
.replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
|
||||||
|
.replace(/-+/g, '-') // collapse multiple hyphens
|
||||||
|
.replace(/^-|-$/g, '') // remove leading/trailing hyphens
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
|
||||||
|
* @param str - The string to convert
|
||||||
|
* @returns The camelCase string
|
||||||
|
*/
|
||||||
|
export function toCamelCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
|
||||||
|
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
|
||||||
|
.replace(/^[A-Z]/, (char) => char.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
|
||||||
|
* @param str - The string to convert
|
||||||
|
* @returns The PascalCase string
|
||||||
|
*/
|
||||||
|
export function toPascalCase(str: string): string {
|
||||||
|
const camel = toCamelCase(str);
|
||||||
|
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize the first letter of a string
|
||||||
|
* @param str - The string to capitalize
|
||||||
|
* @returns The string with first letter capitalized
|
||||||
|
*/
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
if (str.length === 0) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove duplicate whitespace from a string, preserving single spaces
|
||||||
|
* @param str - The string to clean
|
||||||
|
* @returns The string with duplicate whitespace removed
|
||||||
|
*/
|
||||||
|
export function collapseWhitespace(str: string): string {
|
||||||
|
return str.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is empty or contains only whitespace
|
||||||
|
* @param str - The string to check
|
||||||
|
* @returns True if the string is blank
|
||||||
|
*/
|
||||||
|
export function isBlank(str: string | null | undefined): boolean {
|
||||||
|
return str === null || str === undefined || str.trim().length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is not empty and contains non-whitespace characters
|
||||||
|
* @param str - The string to check
|
||||||
|
* @returns True if the string is not blank
|
||||||
|
*/
|
||||||
|
export function isNotBlank(str: string | null | undefined): boolean {
|
||||||
|
return !isBlank(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse a string to an integer, returning a default value on failure
|
||||||
|
* @param str - The string to parse
|
||||||
|
* @param defaultValue - The default value if parsing fails (default: 0)
|
||||||
|
* @returns The parsed integer or the default value
|
||||||
|
*/
|
||||||
|
export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
|
||||||
|
if (isBlank(str)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInt(str!, 10);
|
||||||
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a slug from a string (URL-friendly identifier)
|
||||||
|
* @param str - The string to convert to a slug
|
||||||
|
* @param maxLength - Optional maximum length for the slug
|
||||||
|
* @returns The slugified string
|
||||||
|
*/
|
||||||
|
export function slugify(str: string, maxLength?: number): string {
|
||||||
|
let slug = str
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD') // Normalize unicode characters
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
|
||||||
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||||
|
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||||
|
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||||
|
|
||||||
|
if (maxLength !== undefined && slug.length > maxLength) {
|
||||||
|
// Truncate at word boundary if possible
|
||||||
|
slug = slug.slice(0, maxLength);
|
||||||
|
const lastHyphen = slug.lastIndexOf('-');
|
||||||
|
if (lastHyphen > maxLength * 0.5) {
|
||||||
|
slug = slug.slice(0, lastHyphen);
|
||||||
|
}
|
||||||
|
slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special regex characters in a string
|
||||||
|
* @param str - The string to escape
|
||||||
|
* @returns The escaped string safe for use in a RegExp
|
||||||
|
*/
|
||||||
|
export function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluralize a word based on count
|
||||||
|
* @param word - The singular form of the word
|
||||||
|
* @param count - The count to base pluralization on
|
||||||
|
* @param pluralForm - Optional custom plural form (default: word + 's')
|
||||||
|
* @returns The word in singular or plural form
|
||||||
|
*/
|
||||||
|
export function pluralize(word: string, count: number, pluralForm?: string): string {
|
||||||
|
if (count === 1) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
return pluralForm || `${word}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a count with its associated word (e.g., "1 item", "3 items")
|
||||||
|
* @param count - The count
|
||||||
|
* @param singular - The singular form of the word
|
||||||
|
* @param plural - Optional custom plural form
|
||||||
|
* @returns Formatted string with count and word
|
||||||
|
*/
|
||||||
|
export function formatCount(count: number, singular: string, plural?: string): string {
|
||||||
|
return `${count} ${pluralize(singular, count, plural)}`;
|
||||||
|
}
|
||||||
@@ -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
15
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user