Skip to content

Commit 6ae4067

Browse files
wesmclaude
andauthored
Update default Gemini model and add model-not-found fallback (#517)
## Summary - Update default Gemini model from `gemini-3.1-pro-preview` to `gemini-3.1-pro` now that it's promoted out of preview - When the Gemini CLI fails with a model-not-found error, automatically retry without the `-m` flag to let the CLI use its own default model, making roborev resilient to Google renaming models - Extract `runGemini` helper to support the retry without duplicating pipe/parse logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b9709c commit 6ae4067

2 files changed

Lines changed: 189 additions & 35 deletions

File tree

internal/agent/gemini.go

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"log"
1112
"os/exec"
1213
"strings"
1314
)
@@ -27,6 +28,10 @@ func truncateStderr(stderr string) string {
2728
return stderr[:maxStderrLen] + "... (truncated)"
2829
}
2930

31+
// defaultGeminiModel is the built-in default that may be auto-retried
32+
// without -m if Google retires the model name.
33+
const defaultGeminiModel = "gemini-3.1-pro-preview"
34+
3035
// GeminiAgent runs code reviews using the Gemini CLI
3136
type GeminiAgent struct {
3237
Command string // The gemini command to run (default: "gemini")
@@ -40,7 +45,7 @@ func NewGeminiAgent(command string) *GeminiAgent {
4045
if command == "" {
4146
command = "gemini"
4247
}
43-
return &GeminiAgent{Command: command, Model: "gemini-3.1-pro-preview", Reasoning: ReasoningStandard}
48+
return &GeminiAgent{Command: command, Model: defaultGeminiModel, Reasoning: ReasoningStandard}
4449
}
4550

4651
// WithReasoning returns a copy of the agent with the model preserved (reasoning not yet supported).
@@ -91,86 +96,115 @@ func (a *GeminiAgent) CommandLine() string {
9196
}
9297

9398
func (a *GeminiAgent) buildArgs(agenticMode bool) []string {
94-
// Use stream-json output for parsing, prompt via stdin
99+
return a.buildArgsWithModel(a.Model, agenticMode)
100+
}
101+
102+
func (a *GeminiAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
103+
agenticMode := a.Agentic || AllowUnsafeAgents()
104+
args := a.buildArgs(agenticMode)
105+
106+
result, stderrStr, err := a.runGemini(ctx, repoPath, prompt, args, output)
107+
if err != nil && a.Model == defaultGeminiModel && isModelNotFoundError(stderrStr) {
108+
// Built-in default model may be stale (Google renames
109+
// frequently). Retry without -m to let the Gemini CLI use
110+
// its own default. Non-default models (set via WithModel /
111+
// config) fail fast so config errors are surfaced.
112+
log.Printf("gemini: model %q not found, retrying without -m flag", a.Model)
113+
noModelArgs := a.buildArgsWithModel("", agenticMode)
114+
result, _, err = a.runGemini(ctx, repoPath, prompt, noModelArgs, output)
115+
}
116+
return result, err
117+
}
118+
119+
// buildArgsWithModel builds CLI args with an explicit model override
120+
// (empty string omits the -m flag entirely).
121+
func (a *GeminiAgent) buildArgsWithModel(model string, agenticMode bool) []string {
95122
args := []string{"--output-format", "stream-json"}
96123

97-
if a.Model != "" {
98-
args = append(args, "-m", a.Model)
124+
if model != "" {
125+
args = append(args, "-m", model)
99126
}
100127

101128
if agenticMode {
102-
// Agentic mode: auto-approve all actions, allow write tools
103-
args = append(args, "--yolo")
104-
args = append(args, "--allowed-tools", "Edit,Write,Read,Glob,Grep,Bash,Shell")
129+
args = append(args, "--approval-mode", "yolo")
105130
} else {
106-
// Review mode: read-only tools only
107-
args = append(args, "--allowed-tools", "Read,Glob,Grep")
131+
args = append(args, "--approval-mode", "plan")
108132
}
109133
return args
110134
}
111135

112-
func (a *GeminiAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
113-
// Use agentic mode if either per-job setting or global setting enables it
114-
agenticMode := a.Agentic || AllowUnsafeAgents()
115-
args := a.buildArgs(agenticMode)
116-
136+
// runGemini executes the Gemini CLI with the given args and returns
137+
// the review result, captured stderr, and any error.
138+
func (a *GeminiAgent) runGemini(ctx context.Context, repoPath, prompt string, args []string, output io.Writer) (string, string, error) {
117139
cmd := exec.CommandContext(ctx, a.Command, args...)
118140
cmd.Dir = repoPath
119141
tracker := configureSubprocess(cmd)
120142

121-
// Pipe prompt via stdin
122143
cmd.Stdin = strings.NewReader(prompt)
123144

124-
// Create one shared sync writer for thread-safe output
125145
sw := newSyncWriter(output)
126146

127147
var stderr bytes.Buffer
128148
stdoutPipe, err := cmd.StdoutPipe()
129149
if err != nil {
130-
return "", fmt.Errorf("create stdout pipe: %w", err)
150+
return "", "", fmt.Errorf("create stdout pipe: %w", err)
131151
}
132152
stopClosingPipe := closeOnContextDone(ctx, stdoutPipe)
133153
defer stopClosingPipe()
134-
// Tee stderr to output writer for live error visibility
135154
if sw != nil {
136155
cmd.Stderr = io.MultiWriter(&stderr, sw)
137156
} else {
138157
cmd.Stderr = &stderr
139158
}
140159

141160
if err := cmd.Start(); err != nil {
142-
return "", fmt.Errorf("start gemini: %w", err)
161+
return "", "", fmt.Errorf("start gemini: %w", err)
143162
}
144163

145-
// Parse stream-json output
146164
parsed, parseErr := a.parseStreamJSON(stdoutPipe, sw)
147165

148-
if waitErr := cmd.Wait(); waitErr != nil {
166+
// Wait() is the synchronization point: all stderr writes are
167+
// guaranteed complete after it returns.
168+
waitErr := cmd.Wait()
169+
stderrStr := stderr.String()
170+
171+
if waitErr != nil {
149172
if ctxErr := contextProcessError(ctx, tracker, waitErr, parseErr); ctxErr != nil {
150-
return "", ctxErr
173+
return "", stderrStr, ctxErr
151174
}
152175
if parseErr != nil {
153-
return "", fmt.Errorf("gemini failed: %w (parse error: %v)\nstderr: %s", waitErr, parseErr, truncateStderr(stderr.String()))
176+
return "", stderrStr, fmt.Errorf("gemini failed: %w (parse error: %v)\nstderr: %s", waitErr, parseErr, truncateStderr(stderrStr))
154177
}
155-
return "", fmt.Errorf("gemini failed: %w\nstderr: %s", waitErr, truncateStderr(stderr.String()))
178+
return "", stderrStr, fmt.Errorf("gemini failed: %w\nstderr: %s", waitErr, truncateStderr(stderrStr))
156179
}
157180

158181
if ctxErr := contextProcessError(ctx, tracker, nil, parseErr); ctxErr != nil {
159-
return "", ctxErr
182+
return "", stderrStr, ctxErr
160183
}
161184

162185
if parseErr != nil {
163186
if errors.Is(parseErr, errNoStreamJSON) {
164-
return "", fmt.Errorf("gemini CLI must support --output-format stream-json; upgrade to latest version\nstderr: %s: %w", truncateStderr(stderr.String()), errNoStreamJSON)
187+
return "", stderrStr, fmt.Errorf("gemini CLI must support --output-format stream-json; upgrade to latest version\nstderr: %s: %w", truncateStderr(stderrStr), errNoStreamJSON)
165188
}
166-
return "", parseErr
189+
return "", stderrStr, parseErr
167190
}
168191

169192
if parsed.result != "" {
170-
return parsed.result, nil
193+
return parsed.result, stderrStr, nil
171194
}
172195

173-
return "No review output generated", nil
196+
return "No review output generated", stderrStr, nil
197+
}
198+
199+
// isModelNotFoundError returns true if stderr indicates the requested
200+
// model does not exist. Google's API returns 404 with "model not found"
201+
// or "is not found" messages when a model name is invalid or retired.
202+
func isModelNotFoundError(stderr string) bool {
203+
lower := strings.ToLower(stderr)
204+
return strings.Contains(lower, "model") &&
205+
(strings.Contains(lower, "not found") ||
206+
strings.Contains(lower, "is not found") ||
207+
strings.Contains(lower, "not_found"))
174208
}
175209

176210
// geminiStreamMessage represents a message in Gemini's stream-json output format

internal/agent/gemini_test.go

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,22 @@ func TestGeminiBuildArgs(t *testing.T) {
4444
agentic: false,
4545
wantArgPairs: map[string]string{
4646
"--output-format": "stream-json",
47-
"--allowed-tools": "Read,Glob,Grep",
47+
"--approval-mode": "plan",
4848
},
4949
unwantedArgs: []string{
5050
"--yolo",
51-
"Edit", "Write", "Bash", "Shell",
51+
"--allowed-tools",
5252
},
5353
},
5454
{
55-
name: "AgenticMode",
56-
agentic: true,
57-
wantFlags: []string{"--yolo"},
55+
name: "AgenticMode",
56+
agentic: true,
5857
wantArgPairs: map[string]string{
5958
"--output-format": "stream-json",
60-
"--allowed-tools": "Edit,Write,Read,Glob,Grep,Bash,Shell",
59+
"--approval-mode": "yolo",
60+
},
61+
unwantedArgs: []string{
62+
"--allowed-tools",
6163
},
6264
},
6365
}
@@ -355,6 +357,124 @@ exit 1
355357
}
356358
}
357359

360+
func TestGeminiReview_ModelNotFoundFallback(t *testing.T) {
361+
skipIfWindows(t)
362+
363+
// Script that fails with "model not found" when -m is passed,
364+
// and succeeds without it (simulating a retired default model).
365+
scriptPath := writeTempCommand(t, `#!/bin/sh
366+
for arg in "$@"; do
367+
if [ "$arg" = "-m" ]; then
368+
echo "Error: model is not found for API version v1" >&2
369+
exit 1
370+
fi
371+
done
372+
echo '{"type":"result","result":"Review from default model"}'
373+
`)
374+
375+
a := NewGeminiAgent(scriptPath)
376+
var output bytes.Buffer
377+
res, err := a.Review(
378+
context.Background(), t.TempDir(), "sha", "prompt", &output,
379+
)
380+
require.NoError(t, err)
381+
assert.Equal(t, "Review from default model", res)
382+
}
383+
384+
func TestGeminiReview_ModelNotFoundLateStderr(t *testing.T) {
385+
skipIfWindows(t)
386+
387+
// Regression: stderr emitted only at exit (after stdout closes).
388+
// Previously stderrStr was read before cmd.Wait(), racing with
389+
// the goroutine writing stderr.
390+
scriptPath := writeTempCommand(t, `#!/bin/sh
391+
echo '{"type":"system","subtype":"init"}'
392+
# Close stdout before writing stderr, simulating late error output.
393+
exec 1>&-
394+
sleep 0.05
395+
echo "Error: model is not found for API version v1" >&2
396+
exit 1
397+
`)
398+
399+
a := NewGeminiAgent(scriptPath)
400+
var output bytes.Buffer
401+
// The retry (without -m) will also fail since the script always
402+
// exits 1, but the important thing is that stderr is captured
403+
// correctly and the model-not-found detection triggers the retry.
404+
_, err := a.Review(
405+
context.Background(), t.TempDir(), "sha", "prompt", &output,
406+
)
407+
require.Error(t, err)
408+
assert.Contains(t, err.Error(), "gemini failed",
409+
"should fail on retry but not panic or lose stderr")
410+
}
411+
412+
func TestGeminiReview_ModelNotFoundNoRetryWhenNoModel(t *testing.T) {
413+
skipIfWindows(t)
414+
415+
// When Model is empty, don't retry on model-not-found.
416+
scriptPath := writeTempCommand(t, `#!/bin/sh
417+
echo "Error: model is not found" >&2
418+
exit 1
419+
`)
420+
421+
a := NewGeminiAgent(scriptPath)
422+
a.Model = ""
423+
var output bytes.Buffer
424+
_, err := a.Review(
425+
context.Background(), t.TempDir(), "sha", "prompt", &output,
426+
)
427+
require.Error(t, err)
428+
assert.Contains(t, err.Error(), "gemini failed")
429+
}
430+
431+
func TestGeminiReview_ExplicitModelNoFallback(t *testing.T) {
432+
skipIfWindows(t)
433+
434+
// When the model is not the built-in default, model-not-found
435+
// errors should fail fast with exactly one invocation.
436+
counterFile := filepath.Join(t.TempDir(), "invocations")
437+
scriptPath := writeTempCommand(t, `#!/bin/sh
438+
echo "invoked" >> "$COUNTER_FILE"
439+
echo "Error: model is not found for API version v1" >&2
440+
exit 1
441+
`)
442+
443+
t.Setenv("COUNTER_FILE", counterFile)
444+
a := NewGeminiAgent(scriptPath).WithModel("user-specified-model")
445+
var output bytes.Buffer
446+
_, err := a.Review(
447+
context.Background(), t.TempDir(), "sha", "prompt", &output,
448+
)
449+
require.Error(t, err)
450+
assert.Contains(t, err.Error(), "gemini failed")
451+
452+
countBytes, readErr := os.ReadFile(counterFile)
453+
require.NoError(t, readErr)
454+
lines := strings.Count(string(countBytes), "invoked")
455+
assert.Equal(t, 1, lines,
456+
"explicit model should invoke exactly once, not retry")
457+
}
458+
459+
func TestIsModelNotFoundError(t *testing.T) {
460+
tests := []struct {
461+
stderr string
462+
want bool
463+
}{
464+
{"models/gemini-3.1-pro is not found", true},
465+
{"Error: model is not found for API version v1", true},
466+
{"Model not found: gemini-old", true},
467+
{"NOT_FOUND: model gemini-xyz not_found", true},
468+
{"quota exceeded for model gemini-2.5-pro", false},
469+
{"connection refused", false},
470+
{"", false},
471+
}
472+
for _, tc := range tests {
473+
got := isModelNotFoundError(tc.stderr)
474+
assert.Equal(t, tc.want, got, "isModelNotFoundError(%q)", tc.stderr)
475+
}
476+
}
477+
358478
func TestGeminiReview_PromptDeliveredViaStdin(t *testing.T) {
359479
assert := assert.New(t)
360480

0 commit comments

Comments
 (0)