mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-12 02:04:17 +00:00
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:
parent
4bcc95acec
commit
3c60eb6af8
18 changed files with 290 additions and 14 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}`),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 1–9 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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue