Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/ralphex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
Expand Down Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/defaults/config
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions pkg/config/values.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions pkg/config/values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
5 changes: 5 additions & 0 deletions pkg/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions pkg/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
52 changes: 43 additions & 9 deletions pkg/processor/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down