feat: enhance SDK options with thinking level support

- Introduced a new function, buildThinkingOptions, to handle the conversion of ThinkingLevel to maxThinkingTokens for the Claude SDK.
- Updated existing SDK option creation functions to incorporate thinking options, ensuring that maxThinkingTokens are included based on the specified thinking level.
- Enhanced the settings service to support migration of phase models to include thinking levels, improving compatibility with new configurations.
- Added comprehensive tests for thinking level integration and migration logic, ensuring robust functionality across the application.

This update significantly improves the SDK's configurability and performance by allowing for more nuanced control over reasoning capabilities.
This commit is contained in:
Shirone
2026-01-02 14:55:52 +01:00
parent 914734cff6
commit 81d300391d
27 changed files with 1134 additions and 101 deletions

View File

@@ -19,7 +19,13 @@ import type { Options } from '@anthropic-ai/claude-agent-sdk';
import os from 'os';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
import {
DEFAULT_MODELS,
CLAUDE_MODEL_MAP,
type McpServerConfig,
type ThinkingLevel,
getThinkingTokenBudget,
} from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/**
@@ -317,6 +323,21 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
};
}
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
*
* @param thinkingLevel - The thinking level to convert
* @returns Object with maxThinkingTokens if thinking is enabled
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
console.log(
`[SDK-Options] buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
);
return maxThinkingTokens ? { maxThinkingTokens } : {};
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
@@ -409,6 +430,9 @@ export interface CreateSdkOptionsConfig {
/** Allow unrestricted tools when MCP servers are enabled */
mcpUnrestrictedTools?: boolean;
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
}
// Re-export MCP types from @automaker/types for convenience
@@ -435,6 +459,9 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
@@ -446,6 +473,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
@@ -467,6 +495,9 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
@@ -476,6 +507,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -496,6 +528,9 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
@@ -503,6 +538,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
@@ -531,6 +567,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
@@ -550,6 +589,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
},
}),
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
@@ -575,6 +615,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
@@ -594,6 +637,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
},
}),
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
@@ -621,6 +665,9 @@ export function createCustomOptions(
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
@@ -638,6 +685,7 @@ export function createCustomOptions(
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};

View File

@@ -8,6 +8,7 @@
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
import { getThinkingTokenBudget } from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -60,8 +61,12 @@ export class ClaudeProvider extends BaseProvider {
abortController,
conversationHistory,
sdkSessionId,
thinkingLevel,
} = options;
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
@@ -103,6 +108,8 @@ export class ClaudeProvider extends BaseProvider {
...(options.sandbox && { sandbox: options.sandbox }),
// Forward MCP servers configuration
...(options.mcpServers && { mcpServers: options.mcpServers }),
// Extended thinking configuration
...(maxThinkingTokens && { maxThinkingTokens }),
};
// Build prompt payload

View File

@@ -10,7 +10,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logAuthStatus } from './common.js';
@@ -109,9 +109,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const featureGenerationModel =
const phaseModelEntry =
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
const model = resolveModelString(featureGenerationModel);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model);
@@ -172,6 +172,7 @@ CRITICAL INSTRUCTIONS:
abortController,
autoLoadClaudeMd,
model,
thinkingLevel, // Pass thinking level for extended thinking
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));

View File

@@ -17,7 +17,7 @@ import {
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { extractJson } from '../../lib/json-extractor.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
@@ -102,9 +102,9 @@ ${getStructuredSpecPromptInstruction()}`;
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const specGenerationModel =
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const model = resolveModelString(specGenerationModel);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model);
@@ -185,6 +185,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
abortController,
autoLoadClaudeMd,
model,
thinkingLevel, // Pass thinking level for extended thinking
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,

View File

@@ -7,7 +7,8 @@
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -107,10 +108,14 @@ export async function generateBacklogPlan(
// Get the model to use from settings or provided override
let effectiveModel = model;
let thinkingLevel: ThinkingLevel | undefined;
if (!effectiveModel) {
const settings = await settingsService?.getGlobalSettings();
effectiveModel =
const phaseModelEntry =
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
const resolved = resolvePhaseModel(phaseModelEntry);
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
logger.info('[BacklogPlan] Using model:', effectiveModel);
@@ -154,6 +159,7 @@ ${userPrompt}`;
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
});
let responseText = '';

View File

@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolveModelString } from '@automaker/model-resolver';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import * as secureFs from '../../../lib/secure-fs.js';
@@ -182,11 +182,16 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const fileDescriptionModel =
logger.info(
`[DescribeFile] Raw phaseModels from settings:`,
JSON.stringify(settings?.phaseModels, null, 2)
);
const phaseModelEntry =
settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel;
const model = resolveModelString(fileDescriptionModel);
logger.info(`[DescribeFile] fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry));
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.debug(`[DescribeFile] Using model: ${model}`);
logger.info(`[DescribeFile] Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
let description: string;
@@ -231,6 +236,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});
const promptGenerator = (async function* () {

View File

@@ -15,7 +15,7 @@ import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import * as secureFs from '../../../lib/secure-fs.js';
@@ -342,9 +342,9 @@ export function createDescribeImageHandler(
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const imageDescriptionModel =
const phaseModelEntry =
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
const model = resolveModelString(imageDescriptionModel);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(`[${requestId}] Using model: ${model}`);
@@ -395,6 +395,7 @@ export function createDescribeImageHandler(
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});
logger.info(

View File

@@ -17,7 +17,8 @@ import type {
GitHubComment,
LinkedPRInfo,
} from '@automaker/types';
import { isCursorModel } from '@automaker/types';
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { extractJson } from '../../../lib/json-extractor.js';
import { writeValidation } from '../../../lib/validation-storage.js';
@@ -174,6 +175,12 @@ ${prompt}`;
'[ValidateIssue]'
);
// Get thinkingLevel from phase model settings (the model comes from request, but thinkingLevel from settings)
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
const { thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
@@ -181,6 +188,7 @@ ${prompt}`;
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
autoLoadClaudeMd,
thinkingLevel,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,

View File

@@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
@@ -173,9 +173,9 @@ The response will be automatically formatted as structured JSON.`;
// Get model from phase settings (Feature Enhancement = enhancementModel)
const settings = await settingsService?.getGlobalSettings();
const enhancementModel =
const phaseModelEntry =
settings?.phaseModels?.enhancementModel || DEFAULT_PHASE_MODELS.enhancementModel;
const model = resolveModelString(enhancementModel);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('[Suggestions] Using model:', model);
@@ -247,6 +247,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
abortController,
autoLoadClaudeMd,
model, // Pass the model from settings
thinkingLevel, // Pass thinking level for extended thinking
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,

View File

@@ -16,6 +16,8 @@ import type {
ModelProvider,
PipelineConfig,
PipelineStep,
ThinkingLevel,
PlanningMode,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import {
@@ -24,7 +26,7 @@ import {
classifyError,
loadContextFiles,
} from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
import { exec } from 'child_process';
@@ -51,8 +53,7 @@ import {
const execAsync = promisify(exec);
// Planning mode types for spec-driven development
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// PlanningMode type is imported from @automaker/types
interface ParsedTask {
id: string; // e.g., "T001"
@@ -576,6 +577,7 @@ export class AutoModeService {
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
}
);
@@ -734,6 +736,7 @@ export class AutoModeService {
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
}
);
@@ -1049,6 +1052,7 @@ Address the follow-up instructions above. Review the previous work and make the
previousContent: previousContext || undefined,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature?.thinkingLevel,
}
);
@@ -1278,9 +1282,10 @@ Format your response as a structured markdown document.`;
try {
// Get model from phase settings
const settings = await this.settingsService?.getGlobalSettings();
const projectAnalysisModel =
const phaseModelEntry =
settings?.phaseModels?.projectAnalysisModel || DEFAULT_PHASE_MODELS.projectAnalysisModel;
const analysisModel = resolveModelString(projectAnalysisModel, DEFAULT_MODELS.claude);
const { model: analysisModel, thinkingLevel: analysisThinkingLevel } =
resolvePhaseModel(phaseModelEntry);
console.log('[AutoMode] Using model for project analysis:', analysisModel);
const provider = ProviderFactory.getProviderForModel(analysisModel);
@@ -1300,6 +1305,7 @@ Format your response as a structured markdown document.`;
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
autoLoadClaudeMd,
thinkingLevel: analysisThinkingLevel,
});
const options: ExecuteOptions = {
@@ -1311,6 +1317,7 @@ Format your response as a structured markdown document.`;
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
thinkingLevel: analysisThinkingLevel, // Pass thinking level
};
const stream = provider.executeQuery(options);
@@ -1954,6 +1961,7 @@ This helps parse your summary correctly in the output logs.`;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
@@ -2052,6 +2060,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
thinkingLevel: options?.thinkingLevel,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -2096,6 +2105,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
};
// Execute via provider

View File

@@ -28,6 +28,7 @@ import type {
BoardBackgroundSettings,
WorktreeInfo,
PhaseModelConfig,
PhaseModelEntry,
} from '../types/settings.js';
import {
DEFAULT_GLOBAL_SETTINGS,
@@ -157,10 +158,23 @@ export class SettingsService {
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
result.version = SETTINGS_VERSION;
needsSave = true;
}
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
if (storedVersion < 3) {
logger.info(
`Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format`
);
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
}
// Save migrated settings if needed
if (needsSave) {
try {
@@ -179,6 +193,7 @@ export class SettingsService {
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
*
* Handles backwards compatibility for settings created before phaseModels existed.
* Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3).
* Legacy fields take precedence over defaults but phaseModels takes precedence over legacy.
*
* @param settings - Raw settings from file
@@ -190,26 +205,51 @@ export class SettingsService {
// If phaseModels exists, use it (with defaults for any missing fields)
if (settings.phaseModels) {
return {
...DEFAULT_PHASE_MODELS,
...settings.phaseModels,
};
// Merge with defaults and convert any string values to PhaseModelEntry
const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
for (const key of Object.keys(settings.phaseModels) as Array<keyof PhaseModelConfig>) {
const value = settings.phaseModels[key];
if (value !== undefined) {
// Convert string to PhaseModelEntry if needed (v2 -> v3 migration)
merged[key] = this.toPhaseModelEntry(value);
}
}
return merged;
}
// Migrate legacy fields if phaseModels doesn't exist
// These were the only two legacy fields that existed
if (settings.enhancementModel) {
result.enhancementModel = settings.enhancementModel;
result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel);
logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`);
}
if (settings.validationModel) {
result.validationModel = settings.validationModel;
result.validationModel = this.toPhaseModelEntry(settings.validationModel);
logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`);
}
return result;
}
/**
* Convert a phase model value to PhaseModelEntry format
*
* Handles migration from string format (v2) to object format (v3).
* - String values like 'sonnet' become { model: 'sonnet' }
* - Object values are returned as-is (with type assertion)
*
* @param value - Phase model value (string or PhaseModelEntry)
* @returns PhaseModelEntry object
*/
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
if (typeof value === 'string') {
// v2 format: just a model string
return { model: value as PhaseModelEntry['model'] };
}
// v3 format: already a PhaseModelEntry object
return value;
}
/**
* Update global settings with partial changes
*

View File

@@ -24,6 +24,7 @@ export type {
ProjectSettings,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
} from '@automaker/types';
export {

View File

@@ -554,4 +554,203 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController);
});
});
describe('getThinkingTokenBudget (from @automaker/types)', () => {
it('should return undefined for "none" thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget('none')).toBeUndefined();
});
it('should return undefined for undefined thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget(undefined)).toBeUndefined();
});
it('should return 1024 for "low" thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget('low')).toBe(1024);
});
it('should return 10000 for "medium" thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget('medium')).toBe(10000);
});
it('should return 16000 for "high" thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget('high')).toBe(16000);
});
it('should return 32000 for "ultrathink" thinking level', async () => {
const { getThinkingTokenBudget } = await import('@automaker/types');
expect(getThinkingTokenBudget('ultrathink')).toBe(32000);
});
});
describe('THINKING_TOKEN_BUDGET constant', () => {
it('should have correct values for all thinking levels', async () => {
const { THINKING_TOKEN_BUDGET } = await import('@automaker/types');
expect(THINKING_TOKEN_BUDGET.none).toBeUndefined();
expect(THINKING_TOKEN_BUDGET.low).toBe(1024);
expect(THINKING_TOKEN_BUDGET.medium).toBe(10000);
expect(THINKING_TOKEN_BUDGET.high).toBe(16000);
expect(THINKING_TOKEN_BUDGET.ultrathink).toBe(32000);
});
it('should have minimum of 1024 for enabled thinking levels', async () => {
const { THINKING_TOKEN_BUDGET } = await import('@automaker/types');
// Per Claude SDK docs: minimum is 1024 tokens
expect(THINKING_TOKEN_BUDGET.low).toBeGreaterThanOrEqual(1024);
expect(THINKING_TOKEN_BUDGET.medium).toBeGreaterThanOrEqual(1024);
expect(THINKING_TOKEN_BUDGET.high).toBeGreaterThanOrEqual(1024);
expect(THINKING_TOKEN_BUDGET.ultrathink).toBeGreaterThanOrEqual(1024);
});
it('should have ultrathink at or below 32000 to avoid timeouts', async () => {
const { THINKING_TOKEN_BUDGET } = await import('@automaker/types');
// Per Claude SDK docs: above 32000 risks timeouts
expect(THINKING_TOKEN_BUDGET.ultrathink).toBeLessThanOrEqual(32000);
});
});
describe('thinking level integration with SDK options', () => {
describe('createSpecGenerationOptions with thinkingLevel', () => {
it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => {
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
const options = createSpecGenerationOptions({ cwd: '/test/path' });
expect(options.maxThinkingTokens).toBeUndefined();
});
it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
const options = createSpecGenerationOptions({
cwd: '/test/path',
thinkingLevel: 'none',
});
expect(options.maxThinkingTokens).toBeUndefined();
});
it('should include maxThinkingTokens for "low" thinkingLevel', async () => {
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
const options = createSpecGenerationOptions({
cwd: '/test/path',
thinkingLevel: 'low',
});
expect(options.maxThinkingTokens).toBe(1024);
});
it('should include maxThinkingTokens for "high" thinkingLevel', async () => {
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
const options = createSpecGenerationOptions({
cwd: '/test/path',
thinkingLevel: 'high',
});
expect(options.maxThinkingTokens).toBe(16000);
});
it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => {
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
const options = createSpecGenerationOptions({
cwd: '/test/path',
thinkingLevel: 'ultrathink',
});
expect(options.maxThinkingTokens).toBe(32000);
});
});
describe('createAutoModeOptions with thinkingLevel', () => {
it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path' });
expect(options.maxThinkingTokens).toBeUndefined();
});
it('should include maxThinkingTokens for "medium" thinkingLevel', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
thinkingLevel: 'medium',
});
expect(options.maxThinkingTokens).toBe(10000);
});
it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
thinkingLevel: 'ultrathink',
});
expect(options.maxThinkingTokens).toBe(32000);
});
});
describe('createChatOptions with thinkingLevel', () => {
it('should include maxThinkingTokens for enabled thinkingLevel', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
thinkingLevel: 'high',
});
expect(options.maxThinkingTokens).toBe(16000);
});
});
describe('createSuggestionsOptions with thinkingLevel', () => {
it('should include maxThinkingTokens for enabled thinkingLevel', async () => {
const { createSuggestionsOptions } = await import('@/lib/sdk-options.js');
const options = createSuggestionsOptions({
cwd: '/test/path',
thinkingLevel: 'low',
});
expect(options.maxThinkingTokens).toBe(1024);
});
});
describe('createCustomOptions with thinkingLevel', () => {
it('should include maxThinkingTokens for enabled thinkingLevel', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');
const options = createCustomOptions({
cwd: '/test/path',
thinkingLevel: 'medium',
});
expect(options.maxThinkingTokens).toBe(10000);
});
it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');
const options = createCustomOptions({
cwd: '/test/path',
thinkingLevel: 'none',
});
expect(options.maxThinkingTokens).toBeUndefined();
});
});
});
});

View File

@@ -597,6 +597,170 @@ describe('settings-service.ts', () => {
});
});
describe('phase model migration (v2 -> v3)', () => {
it('should migrate string phase models to PhaseModelEntry format', async () => {
// Simulate v2 format with string phase models
const v2Settings = {
version: 2,
theme: 'dark',
phaseModels: {
enhancementModel: 'sonnet',
fileDescriptionModel: 'haiku',
imageDescriptionModel: 'haiku',
validationModel: 'sonnet',
specGenerationModel: 'opus',
featureGenerationModel: 'sonnet',
backlogPlanningModel: 'sonnet',
projectAnalysisModel: 'sonnet',
},
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(v2Settings, null, 2));
const settings = await settingsService.getGlobalSettings();
// Verify all phase models are now PhaseModelEntry objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
expect(settings.version).toBe(SETTINGS_VERSION);
});
it('should preserve PhaseModelEntry objects during migration', async () => {
// Simulate v3 format (already has PhaseModelEntry objects)
const v3Settings = {
version: 3,
theme: 'dark',
phaseModels: {
enhancementModel: { model: 'sonnet', thinkingLevel: 'high' },
fileDescriptionModel: { model: 'haiku' },
imageDescriptionModel: { model: 'haiku', thinkingLevel: 'low' },
validationModel: { model: 'sonnet' },
specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' },
featureGenerationModel: { model: 'sonnet' },
backlogPlanningModel: { model: 'sonnet', thinkingLevel: 'medium' },
projectAnalysisModel: { model: 'sonnet' },
},
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(v3Settings, null, 2));
const settings = await settingsService.getGlobalSettings();
// Verify PhaseModelEntry objects are preserved with thinkingLevel
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
thinkingLevel: 'ultrathink',
});
expect(settings.phaseModels.backlogPlanningModel).toEqual({
model: 'sonnet',
thinkingLevel: 'medium',
});
});
it('should handle mixed format (some string, some object)', async () => {
// Edge case: mixed format (shouldn't happen but handle gracefully)
const mixedSettings = {
version: 2,
theme: 'dark',
phaseModels: {
enhancementModel: 'sonnet', // string
fileDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, // object
imageDescriptionModel: 'haiku', // string
validationModel: { model: 'opus' }, // object without thinkingLevel
specGenerationModel: 'opus',
featureGenerationModel: 'sonnet',
backlogPlanningModel: 'sonnet',
projectAnalysisModel: 'sonnet',
},
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(mixedSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
// Strings should be converted to objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
// Objects should be preserved
expect(settings.phaseModels.fileDescriptionModel).toEqual({
model: 'haiku',
thinkingLevel: 'low',
});
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
});
it('should migrate legacy enhancementModel/validationModel fields', async () => {
// Simulate v1 format with legacy fields
const v1Settings = {
version: 1,
theme: 'dark',
enhancementModel: 'haiku',
validationModel: 'opus',
// No phaseModels object
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(v1Settings, null, 2));
const settings = await settingsService.getGlobalSettings();
// Legacy fields should be migrated to phaseModels
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
// Other fields should use defaults
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
});
it('should use default phase models when none are configured', async () => {
// Simulate empty settings
const emptySettings = {
version: 1,
theme: 'dark',
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(emptySettings, null, 2));
const settings = await settingsService.getGlobalSettings();
// Should use DEFAULT_PHASE_MODELS
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
});
it('should deep merge phaseModels on update', async () => {
// Create initial settings with some phase models
await settingsService.updateGlobalSettings({
phaseModels: {
enhancementModel: { model: 'sonnet', thinkingLevel: 'high' },
},
});
// Update with a different phase model
await settingsService.updateGlobalSettings({
phaseModels: {
specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' },
},
});
const settings = await settingsService.getGlobalSettings();
// Both should be preserved
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
thinkingLevel: 'ultrathink',
});
});
});
describe('atomicWriteJson', () => {
// Skip on Windows as chmod doesn't work the same way (CI runs on Linux)
it.skipIf(process.platform === 'win32')(

View File

@@ -4,10 +4,22 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string | null): ModelAlias | CursorModelId | null {
if (!entry) return null;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
export interface ModelOverrideTriggerProps {
/** Current effective model (from global settings or explicit override) */
currentModel: ModelAlias | CursorModelId;
@@ -53,8 +65,8 @@ export function ModelOverrideTrigger({
const [open, setOpen] = useState(false);
const { phaseModels, enabledCursorModels } = useAppStore();
// Get the global default for this phase
const globalDefault = phase ? phaseModels[phase] : null;
// Get the global default for this phase (extract model string from PhaseModelEntry)
const globalDefault = phase ? extractModel(phaseModels[phase]) : null;
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
export interface UseModelOverrideOptions {
/** Which phase this override is for */
@@ -24,6 +24,16 @@ export interface UseModelOverrideResult {
override: ModelAlias | CursorModelId | null;
}
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
/**
* Hook for managing model overrides per phase
*
@@ -55,7 +65,8 @@ export function useModelOverride({
const { phaseModels } = useAppStore();
const [override, setOverrideState] = useState<ModelAlias | CursorModelId | null>(initialOverride);
const globalDefault = phaseModels[phase];
// Extract model string from PhaseModelEntry (handles both old string format and new object format)
const globalDefault = extractModel(phaseModels[phase]);
const effectiveModel = useMemo(() => {
return override ?? globalDefault;

View File

@@ -23,10 +23,26 @@ import {
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { BacklogPlanResult, BacklogChange, ModelAlias, CursorModelId } from '@automaker/types';
import type {
BacklogPlanResult,
BacklogChange,
ModelAlias,
CursorModelId,
PhaseModelEntry,
} from '@automaker/types';
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
import { useAppStore } from '@/store/app-store';
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
interface BacklogPlanDialogProps {
open: boolean;
onClose: () => void;
@@ -88,8 +104,8 @@ export function BacklogPlanDialog({
// Start generation in background
setIsGeneratingPlan(true);
// Use model override if set, otherwise use global default
const effectiveModel = modelOverride || phaseModels.backlogPlanningModel;
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel);
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
if (!result.success) {
setIsGeneratingPlan(false);
@@ -365,8 +381,8 @@ export function BacklogPlanDialog({
}
};
// Get effective model (override or global default)
const effectiveModel = modelOverride || phaseModels.backlogPlanningModel;
// Get effective model (override or global default) - extract model string from PhaseModelEntry
const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>

View File

@@ -7,11 +7,24 @@ import {
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
/**
* Extract model string from PhaseModelEntry or string (handles both formats)
*/
function extractModel(
entry: PhaseModelEntry | string | undefined
): ModelAlias | CursorModelId | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
interface UseIssueValidationOptions {
selectedIssue: GitHubIssue | null;
showValidationDialog: boolean;
@@ -244,7 +257,8 @@ export function useIssueValidation({
});
// Use provided model override or fall back to phaseModels.validationModel
const modelToUse = model || phaseModels.validationModel;
// Extract model string from PhaseModelEntry (handles both old string format and new object format)
const modelToUse = model || extractModel(phaseModels.validationModel);
try {
const api = getElectronAPI();

View File

@@ -1,16 +1,27 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, GroupedModel } from '@automaker/types';
import type {
ModelAlias,
CursorModelId,
GroupedModel,
PhaseModelEntry,
ThinkingLevel,
} from '@automaker/types';
import {
stripProviderPrefix,
CURSOR_MODEL_GROUPS,
STANDALONE_CURSOR_MODELS,
getModelGroup,
isGroupSelected,
getSelectedVariant,
isCursorModel,
} from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
import {
CLAUDE_MODELS,
CURSOR_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -27,8 +38,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
interface PhaseModelSelectorProps {
label: string;
description: string;
value: ModelAlias | CursorModelId;
onChange: (model: ModelAlias | CursorModelId) => void;
value: PhaseModelEntry;
onChange: (entry: PhaseModelEntry) => void;
}
export function PhaseModelSelector({
@@ -39,10 +50,16 @@ export function PhaseModelSelector({
}: PhaseModelSelectorProps) {
const [open, setOpen] = React.useState(false);
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
const commandListRef = React.useRef<HTMLDivElement>(null);
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
// Extract model and thinking level from value
const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none';
// Close expanded group when trigger scrolls out of view
React.useEffect(() => {
const triggerElement = expandedTriggerRef.current;
@@ -66,6 +83,29 @@ export function PhaseModelSelector({
return () => observer.disconnect();
}, [expandedGroup]);
// Close expanded Claude model popover when trigger scrolls out of view
React.useEffect(() => {
const triggerElement = expandedClaudeTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedClaudeModel) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry.isIntersecting) {
setExpandedClaudeModel(null);
}
},
{
root: listElement,
threshold: 0.1,
}
);
observer.observe(triggerElement);
return () => observer.disconnect();
}, [expandedClaudeModel]);
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
@@ -74,18 +114,31 @@ export function PhaseModelSelector({
// Helper to find current selected model details
const currentModel = React.useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === value);
if (claudeModel) return { ...claudeModel, icon: Brain };
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
if (claudeModel) {
// Add thinking level to label if not 'none'
const thinkingLabel =
selectedThinkingLevel !== 'none'
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
: '';
return {
...claudeModel,
label: `${claudeModel.label}${thinkingLabel}`,
icon: Brain,
};
}
const cursorModel = availableCursorModels.find((m) => stripProviderPrefix(m.id) === value);
const cursorModel = availableCursorModels.find(
(m) => stripProviderPrefix(m.id) === selectedModel
);
if (cursorModel) return { ...cursorModel, icon: Sparkles };
// Check if value is part of a grouped model
const group = getModelGroup(value as CursorModelId);
// Check if selectedModel is part of a grouped model
const group = getModelGroup(selectedModel as CursorModelId);
if (group) {
const variant = getSelectedVariant(group, value as CursorModelId);
const variant = getSelectedVariant(group, selectedModel as CursorModelId);
return {
id: value,
id: selectedModel,
label: `${group.label} (${variant?.label || 'Unknown'})`,
description: group.description,
provider: 'cursor' as const,
@@ -94,7 +147,7 @@ export function PhaseModelSelector({
}
return null;
}, [value, availableCursorModels]);
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
@@ -156,26 +209,24 @@ export function PhaseModelSelector({
return { favorites: favs, claude: cModels, cursor: curModels };
}, [favoriteModels, availableCursorModels]);
const renderModelItem = (model: (typeof CLAUDE_MODELS)[0], type: 'claude' | 'cursor') => {
const isClaude = type === 'claude';
// For Claude, value is model.id. For Cursor, it's stripped ID.
const modelValue = isClaude ? model.id : stripProviderPrefix(model.id);
const isSelected = value === modelValue;
// Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
const modelValue = stripProviderPrefix(model.id);
const isSelected = selectedModel === modelValue;
const isFavorite = favoriteModels.includes(model.id);
const Icon = isClaude ? Brain : Sparkles;
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange(modelValue as ModelAlias | CursorModelId);
onChange({ model: modelValue as CursorModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<Icon
<Sparkles
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
@@ -212,10 +263,138 @@ export function PhaseModelSelector({
);
};
// Render Claude model item with secondary popover for thinking level
const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
const isExpanded = expandedClaudeModel === model.id;
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
className="p-0 data-[selected=true]:bg-transparent"
>
<Popover
open={isExpanded}
onOpenChange={(isOpen) => {
if (!isOpen) {
setExpandedClaudeModel(null);
}
}}
>
<PopoverTrigger asChild>
<div
ref={isExpanded ? expandedClaudeTriggerRef : 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">
<Brain
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')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentThinking !== 'none'
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{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="center"
avoidCollisions={false}
className="w-[220px] p-1"
sideOffset={8}
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({
model: model.id as ModelAlias,
thinkingLevel: level,
});
setExpandedClaudeModel(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 a grouped model with secondary popover for variant selection
const renderGroupedModelItem = (group: GroupedModel) => {
const groupIsSelected = isGroupSelected(group, value as CursorModelId);
const selectedVariant = getSelectedVariant(group, value as CursorModelId);
const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId);
const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId);
const isExpanded = expandedGroup === group.baseId;
const variantTypeLabel =
@@ -293,14 +472,14 @@ export function PhaseModelSelector({
<button
key={variant.id}
onClick={() => {
onChange(variant.id);
onChange({ model: variant.id });
setExpandedGroup(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',
value === variant.id && 'bg-accent text-accent-foreground'
selectedModel === variant.id && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
@@ -315,7 +494,7 @@ export function PhaseModelSelector({
{variant.badge}
</span>
)}
{value === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
</button>
))}
@@ -388,11 +567,11 @@ export function PhaseModelSelector({
return renderGroupedModelItem(filteredGroup);
}
}
// Standalone Cursor model
return renderCursorModelItem(model);
}
return renderModelItem(
model,
model.provider === 'claude' ? 'claude' : 'cursor'
);
// Claude model
return renderClaudeModelItem(model);
});
})()}
</CommandGroup>
@@ -402,7 +581,7 @@ export function PhaseModelSelector({
{claude.length > 0 && (
<CommandGroup heading="Claude Models">
{claude.map((model) => renderModelItem(model, 'claude'))}
{claude.map((model) => renderClaudeModelItem(model))}
</CommandGroup>
)}
@@ -411,7 +590,7 @@ export function PhaseModelSelector({
{/* Grouped models with secondary popover */}
{groupedModels.map((group) => renderGroupedModelItem(group))}
{/* Standalone models */}
{standaloneCursorModels.map((model) => renderModelItem(model, 'cursor'))}
{standaloneCursorModels.map((model) => renderCursorModelItem(model))}
</CommandGroup>
)}
</CommandList>

View File

@@ -10,6 +10,7 @@ import type {
CursorModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
MCPServerConfig,
FeatureStatusWithPipeline,
PipelineConfig,
@@ -786,7 +787,7 @@ export interface AppActions {
setValidationModel: (model: ModelAlias) => void;
// Phase Model actions
setPhaseModel: (phase: PhaseModelKey, model: ModelAlias | CursorModelId) => Promise<void>;
setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise<void>;
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
resetPhaseModels: () => Promise<void>;
toggleFavoriteModel: (modelId: string) => void;
@@ -1652,11 +1653,11 @@ export const useAppStore = create<AppState & AppActions>()(
setValidationModel: (model) => set({ validationModel: model }),
// Phase Model actions
setPhaseModel: async (phase, model) => {
setPhaseModel: async (phase, entry) => {
set((state) => ({
phaseModels: {
...state.phaseModels,
[phase]: model,
[phase]: entry,
},
}));
// Sync to server settings file

View File

@@ -13,4 +13,9 @@ export {
} from '@automaker/types';
// Export resolver functions
export { resolveModelString, getEffectiveModel } from './resolver.js';
export {
resolveModelString,
getEffectiveModel,
resolvePhaseModel,
type ResolvedPhaseModel,
} from './resolver.js';

View File

@@ -15,6 +15,8 @@ import {
PROVIDER_PREFIXES,
isCursorModel,
stripProviderPrefix,
type PhaseModelEntry,
type ThinkingLevel,
} from '@automaker/types';
/**
@@ -98,3 +100,72 @@ export function getEffectiveModel(
): string {
return resolveModelString(explicitModel || sessionModel, defaultModel);
}
/**
* Result of resolving a phase model entry
*/
export interface ResolvedPhaseModel {
/** Resolved model string (full model ID) */
model: string;
/** Optional thinking level for extended thinking */
thinkingLevel?: ThinkingLevel;
}
/**
* Resolve a phase model entry to a model string and thinking level
*
* Handles both legacy format (string) and new format (PhaseModelEntry object).
* This centralizes the pattern used across phase model routes.
*
* @param phaseModel - Phase model entry (string or PhaseModelEntry object)
* @param defaultModel - Fallback model if resolution fails
* @returns Resolved model string and optional thinking level
*
* @remarks
* - For Cursor models, `thinkingLevel` is returned as `undefined` since Cursor
* handles thinking internally via model variants (e.g., 'claude-sonnet-4-thinking')
* - Defensively handles null/undefined from corrupted settings JSON
*
* @example
* ```ts
* const phaseModel = settings?.phaseModels?.enhancementModel || DEFAULT_PHASE_MODELS.enhancementModel;
* const { model, thinkingLevel } = resolvePhaseModel(phaseModel);
* ```
*/
export function resolvePhaseModel(
phaseModel: string | PhaseModelEntry | null | undefined,
defaultModel: string = DEFAULT_MODELS.claude
): ResolvedPhaseModel {
console.log(
`[ModelResolver] resolvePhaseModel called with:`,
JSON.stringify(phaseModel),
`type: ${typeof phaseModel}`
);
// Handle null/undefined (defensive against corrupted JSON)
if (!phaseModel) {
console.log(`[ModelResolver] phaseModel is null/undefined, using default`);
return {
model: resolveModelString(undefined, defaultModel),
thinkingLevel: undefined,
};
}
// Handle legacy string format
if (typeof phaseModel === 'string') {
console.log(`[ModelResolver] phaseModel is string format (legacy): "${phaseModel}"`);
return {
model: resolveModelString(phaseModel, defaultModel),
thinkingLevel: undefined,
};
}
// Handle new PhaseModelEntry object format
console.log(
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}"`
);
return {
model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel,
};
}

View File

@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { resolveModelString, getEffectiveModel } from '../src/resolver';
import { CLAUDE_MODEL_MAP, CURSOR_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types';
import { resolveModelString, getEffectiveModel, resolvePhaseModel } from '../src/resolver';
import {
CLAUDE_MODEL_MAP,
CURSOR_MODEL_MAP,
DEFAULT_MODELS,
type PhaseModelEntry,
} from '@automaker/types';
describe('model-resolver', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
@@ -353,4 +358,182 @@ describe('model-resolver', () => {
expect(DEFAULT_MODELS.claude).toContain('claude-');
});
});
describe('resolvePhaseModel', () => {
describe('with null/undefined input (defensive handling)', () => {
it('should return default model when phaseModel is null', () => {
const result = resolvePhaseModel(null);
expect(result.model).toBe(DEFAULT_MODELS.claude);
expect(result.thinkingLevel).toBeUndefined();
});
it('should return default model when phaseModel is undefined', () => {
const result = resolvePhaseModel(undefined);
expect(result.model).toBe(DEFAULT_MODELS.claude);
expect(result.thinkingLevel).toBeUndefined();
});
it('should use custom default when phaseModel is null', () => {
const customDefault = 'claude-opus-4-20241113';
const result = resolvePhaseModel(null, customDefault);
expect(result.model).toBe(customDefault);
expect(result.thinkingLevel).toBeUndefined();
});
});
describe('with legacy string format (v2 settings)', () => {
it('should resolve Claude alias string', () => {
const result = resolvePhaseModel('sonnet');
expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet);
expect(result.thinkingLevel).toBeUndefined();
});
it('should resolve opus alias string', () => {
const result = resolvePhaseModel('opus');
expect(result.model).toBe(CLAUDE_MODEL_MAP.opus);
expect(result.thinkingLevel).toBeUndefined();
});
it('should resolve haiku alias string', () => {
const result = resolvePhaseModel('haiku');
expect(result.model).toBe(CLAUDE_MODEL_MAP.haiku);
expect(result.thinkingLevel).toBeUndefined();
});
it('should pass through full Claude model string', () => {
const fullModel = 'claude-sonnet-4-20250514';
const result = resolvePhaseModel(fullModel);
expect(result.model).toBe(fullModel);
expect(result.thinkingLevel).toBeUndefined();
});
it('should handle Cursor model string', () => {
const result = resolvePhaseModel('cursor-auto');
expect(result.model).toBe('cursor-auto');
expect(result.thinkingLevel).toBeUndefined();
});
});
describe('with PhaseModelEntry object format (v3 settings)', () => {
it('should resolve model from entry without thinkingLevel', () => {
const entry: PhaseModelEntry = { model: 'sonnet' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet);
expect(result.thinkingLevel).toBeUndefined();
});
it('should resolve model and return thinkingLevel none', () => {
const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'none' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.opus);
expect(result.thinkingLevel).toBe('none');
});
it('should resolve model and return thinkingLevel low', () => {
const entry: PhaseModelEntry = { model: 'sonnet', thinkingLevel: 'low' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.sonnet);
expect(result.thinkingLevel).toBe('low');
});
it('should resolve model and return thinkingLevel medium', () => {
const entry: PhaseModelEntry = { model: 'haiku', thinkingLevel: 'medium' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.haiku);
expect(result.thinkingLevel).toBe('medium');
});
it('should resolve model and return thinkingLevel high', () => {
const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'high' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.opus);
expect(result.thinkingLevel).toBe('high');
});
it('should resolve model and return thinkingLevel ultrathink', () => {
const entry: PhaseModelEntry = { model: 'opus', thinkingLevel: 'ultrathink' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(CLAUDE_MODEL_MAP.opus);
expect(result.thinkingLevel).toBe('ultrathink');
});
it('should handle full Claude model string in entry', () => {
const entry: PhaseModelEntry = {
model: 'claude-opus-4-5-20251101',
thinkingLevel: 'high',
};
const result = resolvePhaseModel(entry);
expect(result.model).toBe('claude-opus-4-5-20251101');
expect(result.thinkingLevel).toBe('high');
});
});
describe('with Cursor models (thinkingLevel should be preserved but unused)', () => {
it('should handle Cursor model entry without thinkingLevel', () => {
const entry: PhaseModelEntry = { model: 'auto' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe('cursor-auto');
expect(result.thinkingLevel).toBeUndefined();
});
it('should preserve thinkingLevel even for Cursor models (caller handles)', () => {
// Note: thinkingLevel is meaningless for Cursor but we don't filter it
// The calling code should check isCursorModel() before using thinkingLevel
const entry: PhaseModelEntry = { model: 'composer-1', thinkingLevel: 'high' };
const result = resolvePhaseModel(entry);
expect(result.model).toBe('cursor-composer-1');
expect(result.thinkingLevel).toBe('high');
});
it('should handle cursor-prefixed model in entry', () => {
const entry: PhaseModelEntry = { model: 'cursor-gpt-4o' as any };
const result = resolvePhaseModel(entry);
expect(result.model).toBe('cursor-gpt-4o');
});
});
describe('edge cases', () => {
it('should handle empty string model in entry', () => {
const entry: PhaseModelEntry = { model: '' as any };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(DEFAULT_MODELS.claude);
expect(result.thinkingLevel).toBeUndefined();
});
it('should handle unknown model alias in entry', () => {
const entry: PhaseModelEntry = { model: 'unknown-model' as any };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(DEFAULT_MODELS.claude);
});
it('should use custom default for unknown model in entry', () => {
const entry: PhaseModelEntry = { model: 'invalid' as any, thinkingLevel: 'high' };
const customDefault = 'claude-haiku-4-5-20251001';
const result = resolvePhaseModel(entry, customDefault);
expect(result.model).toBe(customDefault);
expect(result.thinkingLevel).toBe('high');
});
});
});
});

View File

@@ -2,7 +2,7 @@
* Feature types for AutoMaker feature management
*/
import type { PlanningMode } from './settings.js';
import type { PlanningMode, ThinkingLevel } from './settings.js';
export interface FeatureImagePath {
id: string;
@@ -38,7 +38,7 @@ export interface Feature {
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
thinkingLevel?: string;
thinkingLevel?: ThinkingLevel;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
planSpec?: {

View File

@@ -71,6 +71,7 @@ export type {
PlanningMode,
ThinkingLevel,
ModelProvider,
PhaseModelEntry,
PhaseModelConfig,
PhaseModelKey,
KeyboardShortcuts,
@@ -95,8 +96,10 @@ export {
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
THINKING_TOKEN_BUDGET,
profileHasThinking,
getProfileModelString,
getThinkingTokenBudget,
} from './settings.js';
// Model display constants

View File

@@ -2,6 +2,8 @@
* Shared types for AI model providers
*/
import type { ThinkingLevel } from './settings.js';
/**
* Configuration for a provider instance
*/
@@ -84,6 +86,12 @@ export interface ExecuteOptions {
* Default: false (allows edits)
*/
readOnly?: boolean;
/**
* Extended thinking level for Claude models.
* Controls the amount of reasoning tokens allocated.
* Only applies to Claude models; Cursor models handle thinking internally.
*/
thinkingLevel?: ThinkingLevel;
}
/**

View File

@@ -70,9 +70,46 @@ export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
/**
* Thinking token budget mapping based on Claude SDK documentation.
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
*
* - Minimum budget: 1,024 tokens
* - Complex tasks starting point: 16,000+ tokens
* - Above 32,000: Risk of timeouts (batch processing recommended)
*/
export const THINKING_TOKEN_BUDGET: Record<ThinkingLevel, number | undefined> = {
none: undefined, // Thinking disabled
low: 1024, // Minimum per docs
medium: 10000, // Light reasoning
high: 16000, // Complex tasks (recommended starting point)
ultrathink: 32000, // Maximum safe (above this risks timeouts)
};
/**
* Convert thinking level to SDK maxThinkingTokens value
*/
export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number | undefined {
if (!level || level === 'none') return undefined;
return THINKING_TOKEN_BUDGET[level];
}
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = 'claude' | 'cursor';
/**
* PhaseModelEntry - Configuration for a single phase model
*
* Encapsulates both the model selection and optional thinking level
* for Claude models. Cursor models handle thinking internally.
*/
export interface PhaseModelEntry {
/** The model to use (Claude alias or Cursor model ID) */
model: ModelAlias | CursorModelId;
/** Extended thinking level (only applies to Claude models, defaults to 'none') */
thinkingLevel?: ThinkingLevel;
}
/**
* PhaseModelConfig - Configuration for AI models used in different application phases
*
@@ -83,25 +120,25 @@ export type ModelProvider = 'claude' | 'cursor';
export interface PhaseModelConfig {
// Quick tasks - recommend fast/cheap models (Haiku, Cursor auto)
/** Model for enhancing feature names and descriptions */
enhancementModel: ModelAlias | CursorModelId;
enhancementModel: PhaseModelEntry;
/** Model for generating file context descriptions */
fileDescriptionModel: ModelAlias | CursorModelId;
fileDescriptionModel: PhaseModelEntry;
/** Model for analyzing and describing context images */
imageDescriptionModel: ModelAlias | CursorModelId;
imageDescriptionModel: PhaseModelEntry;
// Validation tasks - recommend smart models (Sonnet, Opus)
/** Model for validating and improving GitHub issues */
validationModel: ModelAlias | CursorModelId;
validationModel: PhaseModelEntry;
// Generation tasks - recommend powerful models (Opus, Sonnet)
/** Model for generating full application specifications */
specGenerationModel: ModelAlias | CursorModelId;
specGenerationModel: PhaseModelEntry;
/** Model for creating features from specifications */
featureGenerationModel: ModelAlias | CursorModelId;
featureGenerationModel: PhaseModelEntry;
/** Model for reorganizing and prioritizing backlog */
backlogPlanningModel: ModelAlias | CursorModelId;
backlogPlanningModel: PhaseModelEntry;
/** Model for analyzing project structure */
projectAnalysisModel: ModelAlias | CursorModelId;
projectAnalysisModel: PhaseModelEntry;
}
/** Keys of PhaseModelConfig for type-safe access */
@@ -559,22 +596,22 @@ export interface ProjectSettings {
/** Default phase model configuration - sensible defaults for each task type */
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
// Quick tasks - use fast models for speed and cost
enhancementModel: 'sonnet',
fileDescriptionModel: 'haiku',
imageDescriptionModel: 'haiku',
enhancementModel: { model: 'sonnet' },
fileDescriptionModel: { model: 'haiku' },
imageDescriptionModel: { model: 'haiku' },
// Validation - use smart models for accuracy
validationModel: 'sonnet',
validationModel: { model: 'sonnet' },
// Generation - use powerful models for quality
specGenerationModel: 'opus',
featureGenerationModel: 'sonnet',
backlogPlanningModel: 'sonnet',
projectAnalysisModel: 'sonnet',
specGenerationModel: { model: 'opus' },
featureGenerationModel: { model: 'sonnet' },
backlogPlanningModel: { model: 'sonnet' },
projectAnalysisModel: { model: 'sonnet' },
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 2;
export const SETTINGS_VERSION = 3;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */