mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user