feat(config): make per-file token limit configurable (#716)

* feat(config): make per-file token limit configurable

* fix(config): separate prompt and completion token limits
This commit is contained in:
林SO 2026-08-07 11:23:38 +08:00 committed by GitHub
parent 4bcc95acec
commit 3c60eb6af8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 290 additions and 14 deletions

View file

@ -146,10 +146,13 @@ func runConfigUnset(key string) error {
if key == "provider" {
return unsetActiveProvider(configPath)
}
if key == "max_tokens" {
return unsetMaxTokens(configPath)
}
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 || parts[1] == "" {
return fmt.Errorf("unset supports provider, custom_providers.<name>, and mcp_servers.<name>")
return fmt.Errorf("unset supports provider, max_tokens, custom_providers.<name>, and mcp_servers.<name>")
}
switch parts[0] {
@ -158,10 +161,25 @@ func runConfigUnset(key string) error {
case "mcp_servers":
return unsetMCPServer(configPath, parts[1])
default:
return fmt.Errorf("unset supports provider, custom_providers.<name>, and mcp_servers.<name>")
return fmt.Errorf("unset supports provider, max_tokens, custom_providers.<name>, and mcp_servers.<name>")
}
}
func unsetMaxTokens(configPath string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.MaxTokens = 0
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("Cleared max_tokens; using the embedded template default.")
return nil
}
func unsetActiveProvider(configPath string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
@ -294,6 +312,7 @@ type MCPServerConfig struct {
type Config struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Providers map[string]ProviderEntry `json:"providers,omitempty"`
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
Llm LlmConfig `json:"llm,omitempty"`
@ -359,6 +378,7 @@ func LoadAppConfig(path string) (*Config, error) {
var supportedConfigKeys = []string{
"provider",
"model",
"max_tokens",
"providers.<name>.<field>",
"custom_providers.<name>.<field>",
"mcp_servers.<name>.<field>",
@ -430,6 +450,12 @@ func setConfigValue(cfg *Config, key, value string) error {
} else {
cfg.Model = value
}
case "max_tokens":
maxTokens, err := strconv.Atoi(value)
if err != nil || maxTokens <= 0 {
return fmt.Errorf("invalid max_tokens %q: must be a positive integer", value)
}
cfg.MaxTokens = maxTokens
case "llm.url", "llm.URL":
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":

View file

@ -6,6 +6,7 @@ package main
import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@ -55,6 +56,43 @@ func TestSetConfigValueModel(t *testing.T) {
}
}
func TestSetConfigValueMaxTokens(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "max_tokens", "200000"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.MaxTokens != 200000 {
t.Errorf("MaxTokens = %d, want 200000", cfg.MaxTokens)
}
}
func TestSetConfigValueMaxTokensRejectsInvalidValues(t *testing.T) {
for _, value := range []string{"0", "-1", "not-a-number"} {
t.Run(value, func(t *testing.T) {
if err := setConfigValue(&Config{}, "max_tokens", value); err == nil {
t.Fatalf("expected max_tokens=%q to be rejected", value)
}
})
}
}
func TestMaxTokensConfigRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := &Config{MaxTokens: 200000}
if err := saveConfig(path, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
loaded, err := LoadAppConfig(path)
if err != nil {
t.Fatalf("LoadAppConfig: %v", err)
}
if loaded.MaxTokens != 200000 {
t.Errorf("MaxTokens = %d, want 200000", loaded.MaxTokens)
}
}
func TestSetConfigValueModelWithProvider(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
@ -372,6 +410,33 @@ func TestSetConfigValueCustomProviderExtraHeaders(t *testing.T) {
// --- unset tests ---
func TestUnsetMaxTokens(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := &Config{Provider: "anthropic", MaxTokens: 200000}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMaxTokens(configPath); err != nil {
t.Fatalf("unsetMaxTokens: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(data), "max_tokens") {
t.Errorf("max_tokens should be omitted after unset: %s", data)
}
loaded, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if loaded.Provider != "anthropic" {
t.Errorf("Provider = %q, want anthropic", loaded.Provider)
}
}
func TestUnsetCustomProvider(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
@ -932,7 +997,7 @@ func TestSetConfigValueUnknownKeyMessage(t *testing.T) {
t.Fatal("expected error for unknown key")
}
want := "unknown config key: bogus.key\n" +
"Supported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" +
"Supported keys: provider, model, max_tokens, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" +
"Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\n" +
"Protocol values: anthropic, openai, openai-responses\n" +
"MCP server fields: type, command, args, env, url, headers, tools, setup"

View file

@ -103,6 +103,23 @@ func TestParseReviewFlags_NegativeMaxTokensBudget(t *testing.T) {
}
}
func TestParseReviewFlags_NegativeMaxTokens(t *testing.T) {
_, err := parseReviewFlags([]string{"--max-tokens", "-1"})
if err == nil {
t.Fatal("expected error for negative max-tokens")
}
}
func TestParseReviewFlags_MaxTokensParsed(t *testing.T) {
opts, err := parseReviewFlags([]string{"--max-tokens", "200000"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.maxTokens != 200000 {
t.Errorf("maxTokens = %d, want 200000", opts.maxTokens)
}
}
func TestParseReviewFlags_BudgetFlagsDefaultZero(t *testing.T) {
opts, err := parseReviewFlags([]string{"--from", "main", "--to", "dev"})
if err != nil {

View file

@ -43,6 +43,7 @@ type reviewOptions struct {
perFileTimeout int
maxTools int
maxGitProcs int
maxTokens int
maxTokensBudget int
preview bool
}
@ -149,6 +150,12 @@ func executeReview(opts reviewOptions) error {
if err != nil {
return err
}
cc.Template.MaxCompletionTokens = cc.Template.MaxTokens
maxTokens, err := resolveMaxTokens(cc.Template.MaxTokens, rt.AppCfg, opts.maxTokens)
if err != nil {
return err
}
cc.Template.MaxTokens = maxTokens
llmIdentity := &jsonLLMIdentity{
Provider: rt.Provider,
Model: rt.Model,

View file

@ -40,6 +40,7 @@ type scanOptions struct {
noDedup bool
noSummary bool
batch string
maxTokens int
maxTokensBudget int
provider string
model string
@ -153,6 +154,12 @@ func executeScan(opts scanOptions) error {
if err != nil {
return err
}
scanTpl.MaxCompletionTokens = scanTpl.MaxTokens
maxTokens, err := resolveMaxTokens(scanTpl.MaxTokens, rt.AppCfg, opts.maxTokens)
if err != nil {
return err
}
scanTpl.MaxTokens = maxTokens
llmIdentity := &jsonLLMIdentity{
Provider: rt.Provider,
Model: rt.Model,

View file

@ -152,6 +152,16 @@ func TestParseScanFlags_RejectsNegativeMaxTokensBudget(t *testing.T) {
}
}
func TestParseScanFlags_RejectsNegativeMaxTokens(t *testing.T) {
_, err := parseScanFlags([]string{"--max-tokens", "-100"})
if err == nil {
t.Fatal("expected error for negative --max-tokens")
}
if !strings.Contains(err.Error(), "--max-tokens") {
t.Errorf("error message = %q; want it to mention --max-tokens", err.Error())
}
}
func TestParseScanFlags_BooleanFlags(t *testing.T) {
opts, err := parseScanFlags([]string{"--no-plan", "--no-dedup", "--no-summary", "--preview"})
if err != nil {
@ -257,6 +267,7 @@ func TestParseScanFlags_IntFlags(t *testing.T) {
"--timeout", "20",
"--max-tools", "50",
"--max-git-procs", "32",
"--max-tokens", "200000",
"--max-tokens-budget", "100000",
})
if err != nil {
@ -274,6 +285,9 @@ func TestParseScanFlags_IntFlags(t *testing.T) {
if opts.maxGitProcs != 32 {
t.Errorf("maxGitProcs = %d", opts.maxGitProcs)
}
if opts.maxTokens != 200000 {
t.Errorf("maxTokens = %d", opts.maxTokens)
}
if opts.maxTokensBudget != 100000 {
t.Errorf("maxTokensBudget = %d", opts.maxTokensBudget)
}

View file

@ -42,6 +42,24 @@ type commonContext struct {
IsGitRepo bool
}
// resolveMaxTokens applies the per-run CLI override, then the saved setting,
// and finally the embedded task-template default.
func resolveMaxTokens(templateDefault int, cfg *Config, cliOverride int) (int, error) {
if cliOverride < 0 {
return 0, fmt.Errorf("--max-tokens must be a non-negative integer")
}
if cliOverride > 0 {
return cliOverride, nil
}
if cfg == nil || cfg.MaxTokens == 0 {
return templateDefault, nil
}
if cfg.MaxTokens < 0 {
return 0, fmt.Errorf("invalid max_tokens in app config: must be a positive integer")
}
return cfg.MaxTokens, nil
}
// loadCommonContext validates the working directory, loads the embedded
// template, raises MaxToolRequestTimes when maxTools exceeds the default,
// resolves the absolute repo path, loads system review rules, and creates

View file

@ -40,11 +40,12 @@ func addExcludeFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
}
func addConcurrencyFlags(cmd *cobra.Command, concurrency, timeout, maxTools, maxGitProcs, maxTokensBudget *int) {
func addConcurrencyFlags(cmd *cobra.Command, concurrency, timeout, maxTools, maxGitProcs, maxTokens, maxTokensBudget *int) {
cmd.Flags().IntVar(concurrency, "concurrency", 8, "max concurrent file reviews")
cmd.Flags().IntVar(timeout, "timeout", 10, "concurrent task timeout in minutes")
cmd.Flags().IntVar(maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)")
cmd.Flags().IntVar(maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
cmd.Flags().IntVar(maxTokens, "max-tokens", 0, "per-file prompt token ceiling (0 = configured or template default)")
cmd.Flags().IntVar(maxTokensBudget, "max-tokens-budget", 0, "cap total token usage (input+output) for this review; dispatch stops once exceeded and skipped files are reported as failed(budget). Partial results are published and review exits 0; it exits non-zero only if every selected item failed (0 = unlimited)")
}
@ -122,6 +123,9 @@ func validateReviewOptions(opts *reviewOptions) error {
if opts.maxGitProcs < 0 {
return fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)")
}
if opts.maxTokens < 0 {
return fmt.Errorf("--max-tokens must be a non-negative integer (0 means use configured or template default)")
}
if opts.maxTokensBudget < 0 {
return fmt.Errorf("--max-tokens-budget must be a non-negative integer (0 means unlimited)")
}
@ -138,6 +142,9 @@ func validateScanOptions(opts *scanOptions) error {
if opts.maxGitProcs < 0 {
return fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)")
}
if opts.maxTokens < 0 {
return fmt.Errorf("--max-tokens must be a non-negative integer (0 means use configured or template default)")
}
if opts.preview && opts.resume != "" {
return fmt.Errorf("--preview and --resume cannot be used together")
}
@ -161,7 +168,7 @@ func registerReviewFlags(cmd *cobra.Command, opts *reviewOptions) {
cmd.RegisterFlagCompletionFunc("resume", completeSessionIDs)
addExcludeFlag(cmd, &opts.excludes)
addOutputFlags(cmd, &opts.outputFormat, &opts.audience)
addConcurrencyFlags(cmd, &opts.concurrency, &opts.perFileTimeout, &opts.maxTools, &opts.maxGitProcs, &opts.maxTokensBudget)
addConcurrencyFlags(cmd, &opts.concurrency, &opts.perFileTimeout, &opts.maxTools, &opts.maxGitProcs, &opts.maxTokens, &opts.maxTokensBudget)
addBackgroundFlags(cmd, &opts.background, &opts.backgroundFile)
addProviderFlag(cmd, &opts.provider)
addModelFlag(cmd, &opts.model)
@ -180,6 +187,7 @@ func registerScanFlags(cmd *cobra.Command, opts *scanOptions) {
cmd.Flags().IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
cmd.Flags().IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file; only takes effect when greater than template default")
cmd.Flags().IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
cmd.Flags().IntVar(&opts.maxTokens, "max-tokens", 0, "per-file prompt token ceiling (0 = configured or template default)")
cmd.Flags().IntVar(&opts.maxTokensBudget, "max-tokens-budget", 0, "cap total token usage; dispatch stops once exceeded (0 = unlimited)")
cmd.Flags().StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the scan")
cmd.Flags().BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be scanned without running the LLM")

View file

@ -12,6 +12,36 @@ import (
"github.com/alibaba/open-code-review/internal/config/rules"
)
func TestResolveMaxTokensPrecedence(t *testing.T) {
tests := []struct {
name string
cfg *Config
cliOverride int
template int
want int
wantErr bool
}{
{name: "template default", template: 58888, want: 58888},
{name: "zero config is unset", cfg: &Config{}, template: 58888, want: 58888},
{name: "saved config", cfg: &Config{MaxTokens: 128000}, template: 58888, want: 128000},
{name: "cli overrides config", cfg: &Config{MaxTokens: 128000}, cliOverride: 200000, template: 58888, want: 200000},
{name: "negative config", cfg: &Config{MaxTokens: -1}, template: 58888, wantErr: true},
{name: "negative cli", cliOverride: -1, template: 58888, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveMaxTokens(tt.template, tt.cfg, tt.cliOverride)
if (err != nil) != tt.wantErr {
t.Fatalf("resolveMaxTokens() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("resolveMaxTokens() = %d, want %d", got, tt.want)
}
})
}
}
func TestApplyCLIExcludes_Empty(t *testing.T) {
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
applyCLIExcludes(cc, nil)

View file

@ -1263,7 +1263,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: a.args.Model,
Messages: messages,
MaxTokens: a.args.Template.MaxTokens,
MaxTokens: a.args.Template.CompletionTokenLimit(),
})
duration := time.Since(startTime)
if err != nil {
@ -1486,7 +1486,7 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: a.args.Model,
Messages: messages,
MaxTokens: a.args.Template.MaxTokens,
MaxTokens: a.args.Template.CompletionTokenLimit(),
})
duration := time.Since(startTime)
if err != nil {

View file

@ -18,6 +18,9 @@ type Template struct {
PlanTask *LlmConversation `json:"PLAN_TASK,omitempty"`
MemoryCompressionTask LlmConversation `json:"MEMORY_COMPRESSION_TASK"`
MaxTokens int `json:"MAX_TOKENS"`
// MaxCompletionTokens is a runtime-only output cap. When zero, callers
// retain the template's historical MaxTokens behavior.
MaxCompletionTokens int `json:"-"`
MaxToolRequestTimes int `json:"MAX_TOOL_REQUEST_TIMES"`
PlanModeLineThreshold int `json:"PLAN_MODE_LINE_THRESHOLD"`
ReLocationTask *LlmConversation `json:"RE_LOCATION_TASK,omitempty"`
@ -33,6 +36,7 @@ type ScanTemplate struct {
MemoryCompressionTask LlmConversation `json:"MEMORY_COMPRESSION_TASK"`
ReLocationTask *LlmConversation `json:"RE_LOCATION_TASK,omitempty"`
MaxTokens int `json:"MAX_TOKENS"`
MaxCompletionTokens int `json:"-"`
ToolRequestWaitTimeMs int `json:"TOOL_REQUEST_WAIT_TIME_MS"`
MaxToolRequestTimes int `json:"MAX_TOOL_REQUEST_TIMES"`
MaxSubtaskExecMinutes int `json:"MAX_SUBTASK_EXECUTION_TIME_MINUTES"`
@ -45,6 +49,23 @@ type ScanTemplate struct {
ProjectSummaryTask *LlmConversation `json:"PROJECT_SUMMARY_TASK,omitempty"`
}
// CompletionTokenLimit returns the output cap for LLM requests. Runtime
// prompt-limit overrides must not silently expand the model's output budget.
func (t Template) CompletionTokenLimit() int {
if t.MaxCompletionTokens > 0 {
return t.MaxCompletionTokens
}
return t.MaxTokens
}
// CompletionTokenLimit is the scan-template counterpart of Template's method.
func (t ScanTemplate) CompletionTokenLimit() int {
if t.MaxCompletionTokens > 0 {
return t.MaxCompletionTokens
}
return t.MaxTokens
}
//go:embed task_template.json prompts/*
var templateFS embed.FS

View file

@ -54,6 +54,22 @@ func TestLoadDefault_HasNoScanFields(t *testing.T) {
}
}
func TestCompletionTokenLimit(t *testing.T) {
review := Template{MaxTokens: 200000}
if got := review.CompletionTokenLimit(); got != 200000 {
t.Fatalf("review fallback = %d, want 200000", got)
}
review.MaxCompletionTokens = 58888
if got := review.CompletionTokenLimit(); got != 58888 {
t.Fatalf("review runtime limit = %d, want 58888", got)
}
scan := ScanTemplate{MaxTokens: 128000, MaxCompletionTokens: 4096}
if got := scan.CompletionTokenLimit(); got != 4096 {
t.Fatalf("scan runtime limit = %d, want 4096", got)
}
}
func TestLoadDefault_FieldsPopulated(t *testing.T) {
tpl, err := LoadDefault()
if err != nil {

View file

@ -231,7 +231,7 @@ func (r *Runner) runCompression(ctx context.Context, msgs []llm.Message, filePat
resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: r.deps.Model,
Messages: compressionMsgs,
MaxTokens: r.deps.Template.MaxTokens,
MaxTokens: r.deps.Template.CompletionTokenLimit(),
})
duration := time.Since(startTime)

View file

@ -207,7 +207,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
Model: r.deps.Model,
Messages: messages,
Tools: r.deps.MainToolDefs,
MaxTokens: r.deps.Template.MaxTokens,
MaxTokens: r.deps.Template.CompletionTokenLimit(),
SessionID: sessionID,
})
duration := time.Since(startTime)
@ -408,7 +408,7 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
if d != nil {
if !diff.ResolveComment(cm, d) && r.deps.Template.ReLocationTask != nil {
rlStart := time.Now()
_, resp, msgs := diff.ReLocateComment(rctx, cm, d, r.deps.LLMClient, r.deps.Template.ReLocationTask, r.deps.Model, r.deps.Template.MaxTokens)
_, resp, msgs := diff.ReLocateComment(rctx, cm, d, r.deps.LLMClient, r.deps.Template.ReLocationTask, r.deps.Model, r.deps.Template.CompletionTokenLimit())
if msgs != nil {
fs := r.deps.Session.GetOrCreateFileSession(cm.Path)
rlRec := fs.AppendTaskRecord(session.ReLocationTask, msgs)

View file

@ -17,10 +17,12 @@ import (
type fakeClient struct {
responses []*llm.ChatResponse
requests []llm.ChatRequest
calls int
}
func (f *fakeClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
func (f *fakeClient) CompletionsWithCtx(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
f.requests = append(f.requests, req)
if f.calls >= len(f.responses) {
content := ""
return &llm.ChatResponse{
@ -125,6 +127,26 @@ func TestRunPerFile_TaskDoneImmediately(t *testing.T) {
}
}
func TestRunPerFile_UsesCompletionTokenLimit(t *testing.T) {
client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}}
deps := newTestDeps(client)
deps.Template.MaxTokens = 200000
deps.Template.MaxCompletionTokens = 58888
runner := NewRunner(deps)
_, _, err := runner.RunPerFile(
context.Background(),
[]llm.Message{llm.NewTextMessage("user", "review")},
"main.go",
)
if err != nil {
t.Fatalf("RunPerFile: %v", err)
}
if got := client.requests[0].MaxTokens; got != 58888 {
t.Fatalf("request MaxTokens = %d, want 58888", got)
}
}
func TestRunPerFile_TaskDoneExplicitDone(t *testing.T) {
client := &fakeClient{responses: []*llm.ChatResponse{
taskDoneResponseWithArguments(`{"state":"DONE"}`),

View file

@ -159,6 +159,7 @@ func toLoopTemplate(s template.ScanTemplate) template.Template {
return template.Template{
MemoryCompressionTask: s.MemoryCompressionTask,
MaxTokens: s.MaxTokens,
MaxCompletionTokens: s.CompletionTokenLimit(),
MaxToolRequestTimes: s.MaxToolRequestTimes,
ReLocationTask: s.ReLocationTask,
}
@ -766,7 +767,7 @@ func (a *Agent) maybeRunPlan(ctx context.Context, it model.ScanItem, rule string
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: a.args.Model,
Messages: messages,
MaxTokens: a.args.Template.MaxTokens,
MaxTokens: a.args.Template.CompletionTokenLimit(),
})
if err != nil {
rec.SetError(err, time.Since(startTime))
@ -819,7 +820,7 @@ func (a *Agent) maybeRunProjectSummary(ctx context.Context, comments []model.Llm
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: a.args.Model,
Messages: messages,
MaxTokens: a.args.Template.MaxTokens,
MaxTokens: a.args.Template.CompletionTokenLimit(),
})
if err != nil {
rec.SetError(err, time.Since(startTime))
@ -894,7 +895,7 @@ func (a *Agent) maybeRunDedup(ctx context.Context, batchIdx, batchStart int) {
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: a.args.Model,
Messages: messages,
MaxTokens: a.args.Template.MaxTokens,
MaxTokens: a.args.Template.CompletionTokenLimit(),
})
if err != nil {
rec.SetError(err, time.Since(startTime))

View file

@ -98,6 +98,7 @@ staged + unstaged + untracked changes in the current directory's repo.
| `--timeout <minutes>` | — | `10` | Per-file deadline. `0` disables the timeout. |
| `--rule <path>` | — | — | Path to a custom JSON review rule file. Overrides the project-level and global `rule.json`. |
| `--max-tools <n>` | — | template default | Max tool-call rounds per file. `0` uses the template default (`30`); values 19 are clamped up to `10`; any value `≥ 10` overrides the template default (even if smaller than `30`). |
| `--max-tokens <n>` | — | config or template default | Per-file prompt token ceiling. Overrides the saved `max_tokens` setting for this run. |
| `--provider <name>` | — | — | Select a configured provider for this run. Names under both `providers` and `custom_providers` are accepted. |
| `--model <name>` | — | — | Override the resolved LLM model for this run (e.g., `claude-opus-4-6`). |
| `--max-git-procs <n>` | — | `16` | Maximum number of concurrent git subprocesses. |

View file

@ -127,6 +127,29 @@ The `timeout_sec` keys are not supported by `ocr config set` — edit
}
```
### Per-file prompt limit
OCR defaults to a 58,888-token prompt ceiling for each file review. Increase
it for a model with a larger context window by saving `max_tokens`:
```bash
ocr config set max_tokens 200000
```
The setting applies to both `ocr review` and `ocr scan`. Use `--max-tokens`
for a one-off override without changing the saved configuration:
```bash
ocr review --max-tokens 200000
ocr scan --max-tokens 200000
```
The per-run flag takes precedence over `max_tokens`; when neither is set, OCR
uses the embedded task-template default. This limit is per file and is
independent of both the model's output-token cap and `--max-tokens-budget`,
which caps total token use for a run. Restore the embedded default with
`ocr config unset max_tokens`.
### Verify connectivity
```bash