diff --git a/cmd/ralphex/main.go b/cmd/ralphex/main.go index 7a79743d..2b153aac 100644 --- a/cmd/ralphex/main.go +++ b/cmd/ralphex/main.go @@ -34,6 +34,8 @@ type opts struct { MaxIterations int `short:"m" long:"max-iterations" description:"maximum task iterations (default: 50)"` MaxExternalIterations int `long:"max-external-iterations" default:"0" description:"override external review iteration limit (0 = auto)"` ReviewPatience int `long:"review-patience" default:"0" description:"terminate external review after N unchanged rounds (0 = disabled)"` + ClaudeModel string `long:"claude-model" description:"model for task execution (e.g., opus, sonnet, haiku)"` + ReviewModel string `long:"review-model" description:"model for review phases (falls back to --claude-model)"` Review bool `short:"r" long:"review" description:"skip task execution, run full review pipeline"` ExternalOnly bool `short:"e" long:"external-only" description:"skip tasks and first review, run only external review loop"` CodexOnly bool `short:"c" long:"codex-only" description:"alias for --external-only (deprecated)"` @@ -834,6 +836,18 @@ func createRunner(req executePlanRequest, o opts, log processor.Logger, holder * reviewPatience = o.ReviewPatience } + // resolve claude model: CLI flag > config file > empty (use CLI default) + claudeModel := req.Config.ClaudeModel + if o.ClaudeModel != "" { + claudeModel = o.ClaudeModel + } + + // resolve review model: CLI flag > config file > claude_model > empty + reviewModel := req.Config.ReviewModel + if o.ReviewModel != "" { + reviewModel = o.ReviewModel + } + r := processor.New(processor.Config{ PlanFile: req.PlanFile, ProgressPath: log.Path(), @@ -848,6 +862,8 @@ func createRunner(req executePlanRequest, o opts, log processor.Logger, holder * CodexEnabled: codexEnabled, FinalizeEnabled: req.Config.FinalizeEnabled, DefaultBranch: req.BaseRef, + ClaudeModel: claudeModel, + ReviewModel: reviewModel, AppConfig: req.Config, }, log, holder) if req.GitSvc != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 0ef76e22..3bc46327 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -44,6 +44,8 @@ const ( type Config struct { ClaudeCommand string `json:"claude_command"` ClaudeArgs string `json:"claude_args"` + ClaudeModel string `json:"claude_model"` // model for task execution (e.g., "opus", "sonnet") + ReviewModel string `json:"review_model"` // model for review phases (falls back to ClaudeModel) CodexEnabled bool `json:"codex_enabled"` CodexEnabledSet bool `json:"-"` // tracks if codex_enabled was explicitly set in config @@ -275,6 +277,8 @@ func loadConfigFromDirs(globalDir, localDir string) (*Config, error) { c := &Config{ ClaudeCommand: values.ClaudeCommand, ClaudeArgs: values.ClaudeArgs, + ClaudeModel: values.ClaudeModel, + ReviewModel: values.ReviewModel, CodexEnabled: values.CodexEnabled, CodexEnabledSet: values.CodexEnabledSet, CodexCommand: values.CodexCommand, diff --git a/pkg/config/defaults/config b/pkg/config/defaults/config index 6c65554e..ce1f24da 100644 --- a/pkg/config/defaults/config +++ b/pkg/config/defaults/config @@ -22,6 +22,16 @@ claude_command = claude # --verbose: enable detailed logging claude_args = --dangerously-skip-permissions --output-format stream-json --verbose +# claude_model: model to use for task execution +# available: opus, sonnet, haiku (or full model IDs like claude-sonnet-4-5-20250929) +# leave empty to use Claude Code's default model +# claude_model = + +# review_model: model to use for review phases (first review, fix loops, finalize) +# falls back to claude_model if not set, then to Claude Code's default +# use a cheaper/faster model (e.g., sonnet) for reviews to reduce cost +# claude_model = + # ------------------------------------------------------------------------------ # codex executor # ------------------------------------------------------------------------------ diff --git a/pkg/config/values.go b/pkg/config/values.go index 0b4640c0..44017c47 100644 --- a/pkg/config/values.go +++ b/pkg/config/values.go @@ -17,6 +17,8 @@ import ( type Values struct { ClaudeCommand string ClaudeArgs string + ClaudeModel string // model for task execution (e.g., "opus", "sonnet", "haiku") + ReviewModel string // model for review phases (falls back to ClaudeModel if empty) ClaudeErrorPatterns []string // patterns to detect in claude output (e.g., rate limit messages) CodexEnabled bool CodexEnabledSet bool // tracks if codex_enabled was explicitly set @@ -179,6 +181,12 @@ func (vl *valuesLoader) parseValuesFromBytes(data []byte) (Values, error) { if key, err := section.GetKey("claude_args"); err == nil { values.ClaudeArgs = key.String() } + if key, err := section.GetKey("claude_model"); err == nil { + values.ClaudeModel = key.String() + } + if key, err := section.GetKey("review_model"); err == nil { + values.ReviewModel = key.String() + } // codex settings if key, err := section.GetKey("codex_enabled"); err == nil { @@ -415,6 +423,12 @@ func (dst *Values) mergeFrom(src *Values) { if src.ClaudeArgs != "" { dst.ClaudeArgs = src.ClaudeArgs } + if src.ClaudeModel != "" { + dst.ClaudeModel = src.ClaudeModel + } + if src.ReviewModel != "" { + dst.ReviewModel = src.ReviewModel + } if src.CodexEnabledSet { dst.CodexEnabled = src.CodexEnabled dst.CodexEnabledSet = true diff --git a/pkg/config/values_test.go b/pkg/config/values_test.go index 06338887..4ee4669c 100644 --- a/pkg/config/values_test.go +++ b/pkg/config/values_test.go @@ -2171,3 +2171,99 @@ func TestValuesLoader_Load_LimitPatternsOverride(t *testing.T) { // local should override global completely (not merge) assert.Equal(t, []string{"local pattern"}, values.ClaudeLimitPatterns) } + +func TestValuesLoader_Load_ClaudeModel(t *testing.T) { + t.Run("parse valid value", func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config") + require.NoError(t, os.WriteFile(cfgPath, []byte(`claude_model = sonnet`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load("", cfgPath) + require.NoError(t, err) + assert.Equal(t, "sonnet", values.ClaudeModel) + }) + + t.Run("not set defaults to empty", func(t *testing.T) { + loader := newValuesLoader(defaultsFS) + values, err := loader.Load("", "") + require.NoError(t, err) + assert.Empty(t, values.ClaudeModel) + }) + + t.Run("full model ID accepted", func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config") + require.NoError(t, os.WriteFile(cfgPath, []byte(`claude_model = claude-sonnet-4-5-20250929`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load("", cfgPath) + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-5-20250929", values.ClaudeModel) + }) +} + +func TestValuesLoader_Load_ReviewModel(t *testing.T) { + t.Run("parse valid value", func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config") + require.NoError(t, os.WriteFile(cfgPath, []byte(`review_model = haiku`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load("", cfgPath) + require.NoError(t, err) + assert.Equal(t, "haiku", values.ReviewModel) + }) + + t.Run("not set defaults to empty", func(t *testing.T) { + loader := newValuesLoader(defaultsFS) + values, err := loader.Load("", "") + require.NoError(t, err) + assert.Empty(t, values.ReviewModel) + }) +} + +func TestValues_mergeFrom_ClaudeModel(t *testing.T) { + t.Run("non-empty overrides", func(t *testing.T) { + dst := Values{ClaudeModel: ""} + src := Values{ClaudeModel: "opus"} + dst.mergeFrom(&src) + assert.Equal(t, "opus", dst.ClaudeModel) + }) + + t.Run("empty preserves existing", func(t *testing.T) { + dst := Values{ClaudeModel: "opus"} + src := Values{ClaudeModel: ""} + dst.mergeFrom(&src) + assert.Equal(t, "opus", dst.ClaudeModel) + }) + + t.Run("local overrides global", func(t *testing.T) { + tmpDir := t.TempDir() + globalCfg := filepath.Join(tmpDir, "global") + localCfg := filepath.Join(tmpDir, "local") + require.NoError(t, os.WriteFile(globalCfg, []byte(`claude_model = opus`), 0o600)) + require.NoError(t, os.WriteFile(localCfg, []byte(`claude_model = haiku`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load(localCfg, globalCfg) + require.NoError(t, err) + assert.Equal(t, "haiku", values.ClaudeModel) + }) +} + +func TestValues_mergeFrom_ReviewModel(t *testing.T) { + t.Run("non-empty overrides", func(t *testing.T) { + dst := Values{ReviewModel: ""} + src := Values{ReviewModel: "sonnet"} + dst.mergeFrom(&src) + assert.Equal(t, "sonnet", dst.ReviewModel) + }) + + t.Run("empty preserves existing", func(t *testing.T) { + dst := Values{ReviewModel: "sonnet"} + src := Values{ReviewModel: ""} + dst.mergeFrom(&src) + assert.Equal(t, "sonnet", dst.ReviewModel) + }) +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 415a6344..79a8b23a 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -191,6 +191,7 @@ type streamEvent struct { type ClaudeExecutor struct { Command string // command to execute, defaults to "claude" Args string // additional arguments (space-separated), defaults to standard args + Model string // model override (e.g., "opus", "sonnet", "haiku"); empty = CLI default OutputHandler func(text string) // called for each text chunk, can be nil Debug bool // enable debug output ErrorPatterns []string // patterns to detect in output (e.g., rate limit messages) @@ -217,6 +218,10 @@ func (e *ClaudeExecutor) Run(ctx context.Context, prompt string) Result { "--verbose", } } + // inject --model flag if a model override is configured + if e.Model != "" { + args = append(args, "--model", e.Model) + } // always append --print to enable non-interactive mode; mirrors old -p flag that was // always appended. wrapper scripts ignore unknown flags via '*) shift ;;' catch-all. args = append(args, "--print") diff --git a/pkg/executor/executor_test.go b/pkg/executor/executor_test.go index fefaa39d..21194b98 100644 --- a/pkg/executor/executor_test.go +++ b/pkg/executor/executor_test.go @@ -1190,3 +1190,28 @@ func TestClaudeExecutor_Run_PatternInSecondToLastBlock(t *testing.T) { require.ErrorAs(t, result.Error, &limitErr) assert.Equal(t, "You've hit your limit", limitErr.Pattern) } + +func TestClaudeExecutor_Run_ModelFlag(t *testing.T) { + jsonStream := `{"type":"content_block_delta","delta":{"type":"text_delta","text":"ok"}}` + + var capturedArgs []string + mock := &mocks.CommandRunnerMock{ + RunFunc: func(_ context.Context, _ string, args ...string) (io.Reader, func() error, error) { + capturedArgs = args + return strings.NewReader(jsonStream), func() error { return nil }, nil + }, + } + + t.Run("model set injects --model flag", func(t *testing.T) { + e := &ClaudeExecutor{Model: "sonnet", cmdRunner: mock} + e.Run(context.Background(), "test") + assert.Contains(t, capturedArgs, "--model") + assert.Contains(t, capturedArgs, "sonnet") + }) + + t.Run("model empty does not inject --model flag", func(t *testing.T) { + e := &ClaudeExecutor{cmdRunner: mock} + e.Run(context.Background(), "test") + assert.NotContains(t, capturedArgs, "--model") + }) +} diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index b707a01b..e6171d43 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -52,6 +52,8 @@ type Config struct { NoColor bool // disable color output IterationDelayMs int // delay between iterations in milliseconds TaskRetryCount int // number of times to retry failed tasks + ClaudeModel string // model for task execution (empty = CLI default) + ReviewModel string // model for review phases (empty = falls back to ClaudeModel) CodexEnabled bool // whether codex review is enabled FinalizeEnabled bool // whether finalize step is enabled DefaultBranch string // default branch name (detected from repo) @@ -94,16 +96,18 @@ type GitChecker interface { // Executors groups the executor dependencies for the Runner. type Executors struct { - Claude Executor - Codex Executor - Custom *executor.CustomExecutor + Claude Executor + ReviewClaude Executor // optional: separate executor for review phases (nil = use Claude) + Codex Executor + Custom *executor.CustomExecutor } // Runner orchestrates the execution loop. type Runner struct { cfg Config log Logger - claude Executor + claude Executor // executor for task phase + reviewClaude Executor // executor for review phases (may differ in model) codex Executor custom *executor.CustomExecutor git GitChecker @@ -135,6 +139,29 @@ func New(cfg Config, log Logger, holder *status.PhaseHolder) *Runner { claudeExec.LimitPatterns = cfg.AppConfig.ClaudeLimitPatterns claudeExec.IdleTimeout = cfg.AppConfig.IdleTimeout } + claudeExec.Model = cfg.ClaudeModel + + // build review executor (shares base config, may use a different model) + reviewModel := cfg.ReviewModel + if reviewModel == "" { + reviewModel = cfg.ClaudeModel // fall back to task model + } + var reviewExec Executor + if reviewModel != cfg.ClaudeModel { + re := &executor.ClaudeExecutor{ + OutputHandler: claudeExec.OutputHandler, + Debug: cfg.Debug, + Model: reviewModel, + } + if cfg.AppConfig != nil { + re.Command = cfg.AppConfig.ClaudeCommand + re.Args = cfg.AppConfig.ClaudeArgs + re.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns + re.LimitPatterns = cfg.AppConfig.ClaudeLimitPatterns + re.IdleTimeout = cfg.AppConfig.IdleTimeout + } + reviewExec = re + } // build codex executor with config values codexExec := &executor.CodexExecutor{ @@ -179,7 +206,7 @@ func New(cfg Config, log Logger, holder *status.PhaseHolder) *Runner { } } - return NewWithExecutors(cfg, log, Executors{Claude: claudeExec, Codex: codexExec, Custom: customExec}, holder) + return NewWithExecutors(cfg, log, Executors{Claude: claudeExec, ReviewClaude: reviewExec, Codex: codexExec, Custom: customExec}, holder) } // NewWithExecutors creates a new Runner with custom executors (for testing). @@ -205,10 +232,17 @@ func NewWithExecutors(cfg Config, log Logger, execs Executors, holder *status.Ph waitOnLimit = cfg.AppConfig.WaitOnLimit } + // if no separate review executor, use the same as task executor + reviewClaude := execs.ReviewClaude + if reviewClaude == nil { + reviewClaude = execs.Claude + } + return &Runner{ cfg: cfg, log: log, claude: execs.Claude, + reviewClaude: reviewClaude, codex: execs.Codex, custom: execs.Custom, phaseHolder: holder, @@ -475,7 +509,7 @@ func (r *Runner) runTaskPhase(ctx context.Context) error { // runClaudeReview runs Claude review with the given prompt until REVIEW_DONE. func (r *Runner) runClaudeReview(ctx context.Context, prompt string) error { - result := r.runWithLimitRetry(ctx, r.claude.Run, prompt, "claude") + result := r.runWithLimitRetry(ctx, r.reviewClaude.Run, prompt, "claude") if result.Error != nil { if err := r.handlePatternMatchError(result.Error, "claude"); err != nil { return err @@ -517,7 +551,7 @@ func (r *Runner) runClaudeReviewLoop(ctx context.Context, promptPrefix ...string // capture HEAD hash before running claude for no-commit detection headBefore := r.headHash() - result := r.runWithLimitRetry(ctx, r.claude.Run, + result := r.runWithLimitRetry(ctx, r.reviewClaude.Run, prefix+r.replacePromptVariables(r.cfg.AppConfig.ReviewSecondPrompt), "claude") if result.Error != nil { if err := r.handlePatternMatchError(result.Error, "claude"); err != nil { @@ -746,7 +780,7 @@ func (r *Runner) runExternalReviewLoop(ctx context.Context, cfg externalReviewCo // pass output to claude for evaluation and fixing r.phaseHolder.Set(status.PhaseClaudeEval) r.log.PrintSection(status.NewClaudeEvalSection()) - claudeResult := r.runWithLimitRetry(loopCtx, r.claude.Run, cfg.buildEvalPrompt(reviewResult.Output), "claude") + claudeResult := r.runWithLimitRetry(loopCtx, r.reviewClaude.Run, cfg.buildEvalPrompt(reviewResult.Output), "claude") // restore codex phase for next iteration r.phaseHolder.Set(status.PhaseCodex) @@ -1228,7 +1262,7 @@ func (r *Runner) runFinalize(ctx context.Context) error { r.log.PrintSection(status.NewGenericSection("finalize step")) prompt := r.replacePromptVariables(r.cfg.AppConfig.FinalizePrompt) - result := r.runWithLimitRetry(ctx, r.claude.Run, prompt, "claude") + result := r.runWithLimitRetry(ctx, r.reviewClaude.Run, prompt, "claude") if result.Error != nil { // propagate context cancellation - user wants to abort