From 9ce7288f81adfc767f4436b00c15e61bebf2edce Mon Sep 17 00:00:00 2001 From: ScarletCarpet Date: Fri, 3 Jul 2026 13:41:57 +0800 Subject: [PATCH] feat(llm): add top_p, top_k, temperature sampling parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brief: Add three commonly-used LLM sampling parameters (top_p, top_k, temperature) with support from both CLI flags and provider config. This improves the stability of LLM outputs but requires considering the trade-offs. Test: - test on deepseekv4 with temp=0.0 and top_p=0.8 and review the same code it got 80% similarity of output. - there is no param field on http request to LLM without customlize temp/topp/topk Change Log: - Add fields to ChatRequest, ClientConfig, and both OpenAI/Anthropic clients - Add ApplyDefaults() to fill unset fields from ClientConfig - Add provider config fields for built-in and custom providers - Add CLI flags --top-p, --top-k, --temperature for review and scan commands - Add config set support for providers..top_p, top_k, temperature - Propagate params through agent.Args → llmloop.Deps → ChatRequest - Update all ChatRequest call sites (plan, main loop, compression, dedup, summary, relocation, llm test) - Update README.md and README.zh-CN.md with new configuration items and CLI flags - Add unit tests for ApplyDefaults, build params, resolver, config set, and flag parsing Discussion: - Suggest deprecating the LLM config, as it duplicates the provider config - One way to set the temperature without this commit is via extra_body, but it is less elegant - Suggest deprecating other languages' READMEs, keeping only English and Chinese to reduce the maintenance workload --- README.md | 16 +++++ README.zh-CN.md | 16 +++++ cmd/opencodereview/config_cmd.go | 34 +++++++++- cmd/opencodereview/config_cmd_test.go | 92 ++++++++++++++++++++++++++ cmd/opencodereview/flags.go | 27 +++++++- cmd/opencodereview/flags_test.go | 85 ++++++++++++++++++++++++ cmd/opencodereview/llm_cmd.go | 9 ++- cmd/opencodereview/provider_tui.go | 19 ++++++ cmd/opencodereview/review_cmd.go | 5 +- cmd/opencodereview/scan_cmd.go | 17 ++++- cmd/opencodereview/shared.go | 45 ++++++++++++- internal/agent/agent.go | 26 ++++++-- internal/diff/relocation.go | 5 ++ internal/llm/client.go | 35 ++++++++++ internal/llm/client_test.go | 93 +++++++++++++++++++++++++++ internal/llm/resolver.go | 27 +++++++- internal/llm/resolver_test.go | 73 +++++++++++++++++++++ internal/llmloop/compression.go | 9 ++- internal/llmloop/loop.go | 15 +++-- internal/scan/agent.go | 34 +++++++--- 20 files changed, 649 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1e82856e..3833a0a4 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,9 @@ Optional settings: | Key | Description | |-----|-------------| | `providers..auth_header` | Auth header: `x-api-key` or `authorization` (default: `authorization`) | +| `providers..top_p` | Nucleus sampling parameter (0.0–1.0) | +| `providers..top_k` | Top-K sampling parameter (non-negative integer) | +| `providers..temperature` | Temperature parameter (0.0–2.0) | | `providers..extra_body` | Custom JSON fields merged into the request body | | `providers..extra_headers` | Comma-separated `key=value` pairs of custom HTTP headers added to every request | | `providers..models` | Model list for interactive selection | @@ -440,6 +443,9 @@ See the [`examples/`](./examples/) directory for integration examples: | `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) | | `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` | | `--model` | — | — | Select or override the LLM model for this review | +| `--top-p` | — | 0 | Nucleus sampling parameter top_p (0 = use provider config default) | +| `--top-k` | — | 0 | Top-K sampling parameter (0 = use provider config default) | +| `--temperature` | — | 0 | Temperature parameter (0 = use provider config default) | | `--rule` | — | — | Path to custom JSON review rules | | `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default | | `--max-git-procs` | — | built-in | Max concurrent git subprocesses | @@ -464,6 +470,9 @@ non-git directories too (it falls back to a filesystem walk that honors `.gitign | `--format` | `-f` | `text` | Output format: `text` or `json` (JSON includes a `project_summary` field) | | `--concurrency` | — | `8` | Max concurrent file scans | | `--rule` | — | — | Path to custom JSON review rules | +| `--top-p` | — | 0 | Nucleus sampling parameter top_p (0 = use provider config default) | +| `--top-k` | — | 0 | Top-K sampling parameter (0 = use provider config default) | +| `--temperature` | — | 0 | Temperature parameter (0 = use provider config default) | | `--repo` | — | current dir | Repository or directory root to scan | Before each run, `ocr scan` prints a rough token-cost estimate. Use `--preview` to see the @@ -497,6 +506,10 @@ ocr review --commit abc123 --format json --audience agent ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 +# Adjust LLM sampling parameters +ocr review --top-p 0.9 --temperature 0.3 +ocr scan --top-p 0.95 --top-k 50 --temperature 0.5 + # Provide requirement context for more targeted review ocr review --background "Adding rate limiting to the login API" @@ -667,6 +680,9 @@ Config file: `~/.opencodereview/config.json` | `providers..model` | string | Model name for the provider | | `providers..models` | array | Optional provider model list for interactive selection | | `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `providers..top_p` | float | Nucleus sampling parameter (0.0–1.0) | +| `providers..top_k` | integer | Top-K sampling parameter (non-negative) | +| `providers..temperature` | float | Temperature parameter (0.0–2.0) | | `providers..extra_body` | object | JSON object merged into every request body | | `providers..timeout_sec` | integer | Per-request HTTP timeout in seconds (default: `300`) | | `providers..extra_headers` | string | Comma-separated `key=value` HTTP headers | diff --git a/README.zh-CN.md b/README.zh-CN.md index d84bd7f9..94d81f63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -211,6 +211,9 @@ ocr config set custom_providers.my-gateway.model gpt-4o | 键 | 描述 | |----|------| | `providers..auth_header` | 认证头:`x-api-key` 或 `authorization`(默认 `authorization`) | +| `providers..top_p` | Nucleus 采样参数 (0.0–1.0) | +| `providers..top_k` | Top-K 采样参数(非负整数) | +| `providers..temperature` | 温度参数 (0.0–2.0) | | `providers..extra_body` | 合并到请求体的自定义 JSON 字段 | | `providers..extra_headers` | 逗号分隔的 `key=value` 键值对,为每个请求添加自定义 HTTP 头 | | `providers..models` | 用于交互式选择的模型列表 | @@ -438,6 +441,9 @@ ocr review \ | `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) | | `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 | | `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 | +| `--top-p` | — | 0 | Nucleus 采样参数 top_p(0 = 使用供应商配置默认值) | +| `--top-k` | — | 0 | Top-K 采样参数(0 = 使用供应商配置默认值) | +| `--temperature` | — | 0 | 温度参数(0 = 使用供应商配置默认值) | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 | | `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 | @@ -459,6 +465,9 @@ ocr review \ | `--batch` | — | `by-language` | 批处理策略:`none`、`by-language` 或 `by-directory` | | `--format` | `-f` | `text` | 输出格式:`text` 或 `json`(JSON 包含 `project_summary` 字段) | | `--concurrency` | — | `8` | 最大并发文件扫描数 | +| `--top-p` | — | 0 | Nucleus 采样参数 top_p(0 = 使用供应商配置默认值) | +| `--top-k` | — | 0 | Top-K 采样参数(0 = 使用供应商配置默认值) | +| `--temperature` | — | 0 | 温度参数(0 = 使用供应商配置默认值) | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--repo` | — | 当前目录 | 要扫描的仓库或目录根路径 | @@ -492,6 +501,10 @@ ocr review --commit abc123 --format json --audience agent ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 +# 修改模型采样参数 +ocr review --top-p 0.9 --temperature 0.3 +ocr scan --top-p 0.95 --top-k 50 --temperature 0.5 + # 提供需求背景以获得更有针对性的审查 ocr review --background "为登录 API 添加限流" @@ -652,6 +665,9 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则 | `providers..model` | string | 供应商模型名称 | | `providers..models` | array | 用于交互式选择的可选供应商模型列表 | | `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `providers..top_p` | float | Nucleus 采样参数 (0.0–1.0) | +| `providers..top_k` | integer | Top-K 采样参数(非负整数) | +| `providers..temperature` | float | 温度参数 (0.0–2.0) | | `providers..extra_body` | object | 合并到每个请求体的 JSON 对象 | | `providers..timeout_sec` | integer | 每次请求的 HTTP 超时时间(秒),默认 `300` | | `providers..extra_headers` | string | 逗号分隔的 `key=value` HTTP 头 | diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index 64b511b2..af910481 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -194,6 +194,9 @@ type ProviderEntry struct { Model string `json:"model,omitempty"` Models []string `json:"models,omitempty"` AuthHeader string `json:"auth_header,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"` } @@ -374,7 +377,7 @@ func setConfigValue(cfg *Config, key, value string) error { } cfg.Llm.ExtraBody = m default: - return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.., custom_providers.., mcp_servers.., llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nMCP server fields: command, args, env, tools, setup", key) + return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.., custom_providers.., mcp_servers.., llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, top_p, top_k, temperature, extra_body, extra_headers\nMCP server fields: command, args, env, tools, setup", key) } return nil } @@ -416,8 +419,35 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error { return fmt.Errorf("invalid extra headers for %s: %w", key, err) } entry.ExtraHeaders = parsed + case "top_p": + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid float for %s: %w", key, err) + } + if f <= 0 || f > 1 { + return fmt.Errorf("top_p must be between 0 (exclusive) and 1, got %f", f) + } + entry.TopP = &f + case "top_k": + i, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid integer for %s: %w", key, err) + } + if i < 0 { + return fmt.Errorf("top_k must be non-negative, got %d", i) + } + entry.TopK = &i + case "temperature": + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid float for %s: %w", key, err) + } + if f < 0 || f > 2 { + return fmt.Errorf("temperature must be between 0 and 2, got %f", f) + } + entry.Temperature = &f default: - return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", field) + return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, top_p, top_k, temperature, extra_body, extra_headers", field) } return nil } diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index 8c7de98d..e735ca83 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -838,6 +838,98 @@ func TestSetConfigValueProviderClearsModel(t *testing.T) { } } +func TestSetConfigValueProviderTopP(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.top_p", "0.9"); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + entry := cfg.Providers["anthropic"] + if entry.TopP == nil || *entry.TopP != 0.9 { + t.Errorf("TopP = %v, want 0.9", entry.TopP) + } +} + +func TestSetConfigValueProviderTopK(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.top_k", "50"); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + entry := cfg.Providers["anthropic"] + if entry.TopK == nil || *entry.TopK != 50 { + t.Errorf("TopK = %v, want 50", entry.TopK) + } +} + +func TestSetConfigValueProviderTemperature(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.temperature", "0.3"); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + entry := cfg.Providers["anthropic"] + if entry.Temperature == nil || *entry.Temperature != 0.3 { + t.Errorf("Temperature = %v, want 0.3", entry.Temperature) + } +} + +func TestSetConfigValueProviderTopPOutOfRange(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.top_p", "1.5"); err == nil { + t.Fatal("expected error for top_p > 1") + } + if err := setConfigValue(cfg, "providers.anthropic.top_p", "0"); err == nil { + t.Fatal("expected error for top_p == 0 (must be > 0)") + } + if err := setConfigValue(cfg, "providers.anthropic.top_p", "-0.1"); err == nil { + t.Fatal("expected error for negative top_p") + } +} + +func TestSetConfigValueProviderTopKNegative(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.top_k", "-1"); err == nil { + t.Fatal("expected error for negative top_k") + } +} + +func TestSetConfigValueProviderTemperatureOutOfRange(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{"anthropic": {}}, + } + if err := setConfigValue(cfg, "providers.anthropic.temperature", "3.0"); err == nil { + t.Fatal("expected error for temperature > 2") + } +} + +func TestSetConfigValueCustomProviderTopP(t *testing.T) { + cfg := &Config{ + Provider: "my-gateway", + CustomProviders: map[string]ProviderEntry{"my-gateway": {}}, + } + if err := setConfigValue(cfg, "custom_providers.my-gateway.top_p", "0.8"); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + entry := cfg.CustomProviders["my-gateway"] + if entry.TopP == nil || *entry.TopP != 0.8 { + t.Errorf("TopP = %v, want 0.8", entry.TopP) + } +} + func TestRunConfigUnset_InvalidKey(t *testing.T) { if err := runConfigUnset("provider"); err == nil { t.Fatal("expected error for non custom_providers key") diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index ccda5158..7b75121a 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -54,6 +54,10 @@ func (a *ocrFlagSet) IntVar(p *int, name string, value int, usage string) { a.fs.IntVar(p, name, value, usage) } +func (a *ocrFlagSet) Float64Var(p *float64, name string, value float64, usage string) { + a.fs.Float64Var(p, name, value, usage) +} + func (a *ocrFlagSet) DurationVar(p *time.Duration, name string, value time.Duration, usage string) { a.fs.DurationVar(p, name, value, usage) } @@ -106,6 +110,9 @@ type reviewOptions struct { audience string // --audience: "human" (default) or "agent" background string // --background: optional requirement context model string // --model: override resolved LLM model for this review + topP float64 + topK int + temperature float64 concurrency int perFileTimeout int maxTools int @@ -132,6 +139,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) { a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)") a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review") a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)") + a.Float64Var(&opts.topP, "top-p", -1, "nucleus sampling parameter top_p (-1 = use provider default)") + a.IntVar(&opts.topK, "top-k", -1, "top-k sampling parameter (-1 = use provider default)") + a.Float64Var(&opts.temperature, "temperature", -1, "temperature parameter (-1 = use provider default)") a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)") a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") a.BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be reviewed without running the LLM") @@ -182,6 +192,16 @@ func parseReviewFlags(args []string) (reviewOptions, error) { return opts, fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)") } + if opts.topP > 1 { + return opts, fmt.Errorf("--top-p must be <= 1, got %f", opts.topP) + } + if opts.temperature > 0 && opts.temperature > 2 { + return opts, fmt.Errorf("--temperature must be <= 2, got %f", opts.temperature) + } + if opts.topK > 0 && opts.topK > 100 { + fmt.Fprintf(os.Stderr, "[ocr] --top-k %d is unusually large, expected <= 100\n", opts.topK) + } + return opts, nil } @@ -229,7 +249,10 @@ Flags: --rule string path to JSON file with system review rules --timeout int concurrent task timeout in minutes (default 10) --to string target ref to end diff at (e.g., 'feature-branch') - --tools string path to JSON tools config file (default: embedded)`) + --tools string path to JSON tools config file (default: embedded) + --top-p float nucleus sampling parameter top_p (0 = use provider default) + --top-k int top-k sampling parameter (0 = use provider default) + --temperature float temperature parameter (0 = use provider default)`) } // --- config subcommand --- @@ -320,6 +343,6 @@ Examples: ocr config set telemetry.enabled true Supported keys: provider, model, providers.., custom_providers.., mcp_servers.., llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging -Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers +Provider fields: api_key, url, protocol, model, models, auth_header, top_p, top_k, temperature, extra_body, extra_headers MCP server fields: command, args, env, tools, setup`) } diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 15701caa..e655c9c7 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -192,3 +192,88 @@ func TestExpandShortFlags(t *testing.T) { }) } } + +func TestParseReviewFlags_LLMParams(t *testing.T) { + opts, err := parseReviewFlags([]string{"--top-p", "0.9", "--top-k", "50", "--temperature", "0.3"}) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.topP != 0.9 { + t.Errorf("topP = %v, want 0.9", opts.topP) + } + if opts.topK != 50 { + t.Errorf("topK = %d, want 50", opts.topK) + } + if opts.temperature != 0.3 { + t.Errorf("temperature = %v, want 0.3", opts.temperature) + } +} + +func TestParseScanFlags_LLMParams(t *testing.T) { + opts, err := parseScanFlags([]string{"--top-p", "0.8", "--temperature", "0.5"}) + if err != nil { + t.Fatalf("parseScanFlags: %v", err) + } + if opts.topP != 0.8 { + t.Errorf("topP = %v, want 0.8", opts.topP) + } + if opts.temperature != 0.5 { + t.Errorf("temperature = %v, want 0.5", opts.temperature) + } +} + +func TestParseReviewFlags_LLMParamsExplicitZero(t *testing.T) { + // temperature=0 and top_k=0 are valid (greedy/deterministic decoding). + opts, err := parseReviewFlags([]string{"--temperature", "0", "--top-k", "0"}) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.temperature != 0 { + t.Errorf("temperature = %v, want 0 (greedy)", opts.temperature) + } + if opts.topK != 0 { + t.Errorf("topK = %d, want 0", opts.topK) + } +} + +func TestParseReviewFlags_LLMParamsDefaults(t *testing.T) { + opts, err := parseReviewFlags(nil) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.topP != -1 { + t.Errorf("topP default = %v, want -1 (use provider default)", opts.topP) + } + if opts.topK != -1 { + t.Errorf("topK default = %d, want -1 (use provider default)", opts.topK) + } + if opts.temperature != -1 { + t.Errorf("temperature default = %v, want -1 (use provider default)", opts.temperature) + } +} + +func TestCliFloatOrNil(t *testing.T) { + // -1 is sentinel for "not set" (flag default), 0 is a valid explicit value. + if v := cliFloatOrNil(-1); v != nil { + t.Errorf("cliFloatOrNil(-1) = %v, want nil", v) + } + if v := cliFloatOrNil(0); v == nil || *v != 0 { + t.Errorf("cliFloatOrNil(0) = %v, want 0", v) + } + if v := cliFloatOrNil(0.5); v == nil || *v != 0.5 { + t.Errorf("cliFloatOrNil(0.5) = %v, want 0.5", v) + } +} + +func TestCliIntOrNil(t *testing.T) { + // -1 is sentinel for "not set" (flag default), 0 is a valid explicit value. + if v := cliIntOrNil(-1); v != nil { + t.Errorf("cliIntOrNil(-1) = %v, want nil", v) + } + if v := cliIntOrNil(0); v == nil || *v != 0 { + t.Errorf("cliIntOrNil(0) = %v, want 0", v) + } + if v := cliIntOrNil(42); v == nil || *v != 42 { + t.Errorf("cliIntOrNil(42) = %v, want 42", v) + } +} diff --git a/cmd/opencodereview/llm_cmd.go b/cmd/opencodereview/llm_cmd.go index 9e33085b..e038d7d6 100644 --- a/cmd/opencodereview/llm_cmd.go +++ b/cmd/opencodereview/llm_cmd.go @@ -70,9 +70,12 @@ func runLLMTest() error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() return llmClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: ep.Model, - Messages: messages, - MaxTokens: 2048, + Model: ep.Model, + Messages: messages, + MaxTokens: 2048, + Temperature: ep.Temperature, + TopP: ep.TopP, + TopK: ep.TopK, }) }() if err != nil { diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go index 2e0b795b..48148aab 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -916,6 +916,19 @@ func cloneProviderEntry(v ProviderEntry) ProviderEntry { Models: append([]string(nil), v.Models...), AuthHeader: v.AuthHeader, } + // Deep-copy pointer fields to prevent aliasing. + if v.TopP != nil { + v2 := *v.TopP + out.TopP = &v2 + } + if v.TopK != nil { + v2 := *v.TopK + out.TopK = &v2 + } + if v.Temperature != nil { + v2 := *v.Temperature + out.Temperature = &v2 + } if v.ExtraBody != nil { out.ExtraBody = make(map[string]any, len(v.ExtraBody)) for k, val := range v.ExtraBody { @@ -923,6 +936,12 @@ func cloneProviderEntry(v ProviderEntry) ProviderEntry { out.ExtraBody[k] = val } } + if v.ExtraHeaders != nil { + out.ExtraHeaders = make(map[string]string, len(v.ExtraHeaders)) + for k, val := range v.ExtraHeaders { + out.ExtraHeaders[k] = val + } + } return out } diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index d76951ba..685f4775 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -48,7 +48,7 @@ func runReview(args []string) error { return runPreview(cc, opts) } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) + rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model, cliFloatOrNil(opts.topP), cliIntOrNil(opts.topK), cliFloatOrNil(opts.temperature)) if err != nil { return err } @@ -95,6 +95,9 @@ func runReview(args []string) error { Model: rt.Model, Background: opts.background, GitRunner: cc.GitRunner, + TopP: rt.TopP, + TopK: rt.TopK, + Temperature: rt.Temperature, }) // Silence progress output during execution; restored before the trace diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index 3757e980..799eac40 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -38,6 +38,9 @@ type scanOptions struct { batch string // --batch: override scan template's BATCH_STRATEGY maxTokensBudget int // --max-tokens-budget: cap total token usage; 0 = unlimited model string // --model: override resolved LLM model for this scan + topP float64 + topK int + temperature float64 showHelp bool } @@ -64,6 +67,9 @@ func parseScanFlags(args []string) (scanOptions, error) { a.StringVar(&opts.batch, "batch", "", "override BATCH_STRATEGY from scan template: none | by-language | by-directory") a.IntVar(&opts.maxTokensBudget, "max-tokens-budget", 0, "cap total token usage (input+output); dispatch stops once exceeded (0 = unlimited)") a.StringVar(&opts.model, "model", "", "override LLM model for this scan (e.g., claude-opus-4-6)") + a.Float64Var(&opts.topP, "top-p", -1, "nucleus sampling parameter top_p (-1 = use provider default)") + a.IntVar(&opts.topK, "top-k", -1, "top-k sampling parameter (-1 = use provider default)") + a.Float64Var(&opts.temperature, "temperature", -1, "temperature parameter (-1 = use provider default)") if err := a.Parse(args); err != nil { return opts, fmt.Errorf("parse flags: %w", err) @@ -89,6 +95,12 @@ func parseScanFlags(args []string) (scanOptions, error) { if opts.maxTokensBudget < 0 { return opts, fmt.Errorf("--max-tokens-budget must be a non-negative integer (0 means unlimited)") } + if opts.topP > 1 { + return opts, fmt.Errorf("--top-p must be <= 1, got %f", opts.topP) + } + if opts.temperature > 0 && opts.temperature > 2 { + return opts, fmt.Errorf("--temperature must be <= 2, got %f", opts.temperature) + } return opts, nil } @@ -156,7 +168,7 @@ func runScan(args []string) error { return runScanPreview(cc, scanTpl, scanPaths) } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) + rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model, cliFloatOrNil(opts.topP), cliIntOrNil(opts.topK), cliFloatOrNil(opts.temperature)) if err != nil { return err } @@ -200,6 +212,9 @@ func runScan(args []string) error { SkipPlan: opts.noPlan, SkipDedup: opts.noDedup, SkipSummary: opts.noSummary, + TopP: rt.TopP, + TopK: rt.TopK, + Temperature: rt.Temperature, }) q := newQuietHandle(opts.outputFormat, opts.audience) diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index 8d898a12..7dfd9f7d 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -114,6 +114,9 @@ type llmRuntime struct { MainToolDefs []llm.ToolDef Collector *tool.CommentCollector AppCfg *Config + TopP *float64 + TopK *int + Temperature *float64 } // loadLLMRuntime loads tool defs from toolConfigPath, reads the app config @@ -121,7 +124,10 @@ type llmRuntime struct { // tpl — defaulting when the config file is absent), resolves the LLM // endpoint (honoring modelOverride from --model when non-empty), and // returns the runtime bundle. tpl is mutated in place. -func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string) (*llmRuntime, error) { +// +// cliTopP, cliTopK, cliTemperature are CLI-level overrides for sampling +// parameters that take precedence over provider config values. +func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string, cliTopP *float64, cliTopK *int, cliTemperature *float64) (*llmRuntime, error) { toolEntries, err := toolsconfig.Load(toolConfigPath) if err != nil { return nil, fmt.Errorf("load tools: %w", err) @@ -150,6 +156,20 @@ func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string return nil, fmt.Errorf("resolve LLM endpoint: %w", err) } + // CLI overrides take precedence over provider config values. + topP := cliTopP + if topP == nil { + topP = ep.TopP + } + topK := cliTopK + if topK == nil { + topK = ep.TopK + } + temperature := cliTemperature + if temperature == nil { + temperature = ep.Temperature + } + return &llmRuntime{ Client: llm.NewLLMClient(ep), Model: ep.Model, @@ -157,6 +177,9 @@ func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string MainToolDefs: mainToolDefs, Collector: tool.NewCommentCollector(), AppCfg: appCfg, + TopP: topP, + TopK: topK, + Temperature: temperature, }, nil } @@ -284,3 +307,23 @@ func emitRunResult( } return nil } + +// cliFloatOrNil returns a *float64 pointer if v >= 0, otherwise nil. +// A negative value means "not set" (flag default). Since temperature, +// top_p, and top_k are all non-negative, -1 is a safe sentinel. +func cliFloatOrNil(v float64) *float64 { + if v < 0 { + return nil + } + return &v +} + +// cliIntOrNil returns a *int pointer if v >= 0, otherwise nil. +// A negative value means "not set" (flag default). Since temperature, +// top_k are non-negative, -1 is a safe sentinel. +func cliIntOrNil(v int) *int { + if v < 0 { + return nil + } + return &v +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4be6504d..cf9437b9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -111,6 +111,11 @@ type Args struct { // Session is an optional session history instance for collecting conversation records. // When nil, a default one is created automatically with git branch auto-detected from repoDir. Session *session.SessionHistory + + // CLI-level overrides for LLM sampling parameters (nil = use provider config). + TopP *float64 + TopK *int + Temperature *float64 } // Agent orchestrates the AI-powered code review. LLM tool-use loop / memory @@ -164,6 +169,9 @@ func New(args Args) *Agent { CommentCollector: args.CommentCollector, CommentWorkerPool: args.CommentWorkerPool, Session: args.Session, + TopP: args.TopP, + TopK: args.TopK, + Temperature: args.Temperature, DiffLookup: a.findDiff, }) return a @@ -535,9 +543,12 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s startTime := time.Now() resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: a.args.Model, - Messages: messages, - MaxTokens: a.args.Template.MaxTokens, + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.MaxTokens, + Temperature: a.args.Temperature, + TopP: a.args.TopP, + TopK: a.args.TopK, }) if err != nil { rec.SetError(err, time.Since(startTime)) @@ -741,9 +752,12 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi startTime := time.Now() resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: a.args.Model, - Messages: messages, - MaxTokens: a.args.Template.MaxTokens, + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.MaxTokens, + Temperature: a.args.Temperature, + TopP: a.args.TopP, + TopK: a.args.TopK, }) if err != nil { rec.SetError(err, time.Since(startTime)) diff --git a/internal/diff/relocation.go b/internal/diff/relocation.go index 1f296e3a..cc16b14a 100644 --- a/internal/diff/relocation.go +++ b/internal/diff/relocation.go @@ -48,6 +48,11 @@ func ReLocateComment( Model: modelName, Messages: messages, MaxTokens: maxTokens, + // Note: relocation intentionally omits sampling overrides (TopP/TopK/Temperature) + // so ApplyDefaults inside CompletionsWithCtx will still apply global + // ClientConfig defaults. Callers that want strict exclusion for relocation + // should either set these to nil on the ClientConfig before calling, or + // add an opt-out mechanism to ChatRequest. }) if err != nil { fmt.Fprintf(stdout.Writer(), "[ocr] Re-location LLM call failed for %s: %v\n", cm.Path, err) diff --git a/internal/llm/client.go b/internal/llm/client.go index 685c7170..eec6258f 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -184,6 +184,9 @@ type ClientConfig struct { Model string // Default model override AuthHeader string // Auth header name: "x-api-key", "authorization", or empty for protocol default Timeout time.Duration // Request timeout + TopP *float64 // Default nucleus sampling parameter + TopK *int // Default top-k sampling parameter + Temperature *float64 // Default temperature parameter ExtraBody map[string]any // Vendor-specific fields merged into every request body ExtraHeaders map[string]string // Extra HTTP headers sent with every request } @@ -199,6 +202,9 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient { Model: ep.Model, AuthHeader: ep.AuthHeader, Timeout: ep.Timeout, + TopP: ep.TopP, + TopK: ep.TopK, + Temperature: ep.Temperature, ExtraBody: ep.ExtraBody, ExtraHeaders: ep.ExtraHeaders, } @@ -317,11 +323,30 @@ type ChatRequest struct { Messages []Message `json:"messages"` Tools []ToolDef `json:"tools,omitempty"` Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` } +// ApplyDefaults fills unset fields from the given ClientConfig defaults. +func (r *ChatRequest) ApplyDefaults(cfg *ClientConfig) { + if r.Temperature == nil && cfg != nil && cfg.Temperature != nil { + v := *cfg.Temperature + r.Temperature = &v + } + if r.TopP == nil && cfg != nil && cfg.TopP != nil { + v := *cfg.TopP + r.TopP = &v + } + if r.TopK == nil && cfg != nil && cfg.TopK != nil { + v := *cfg.TopK + r.TopK = &v + } +} + // CompletionsWithCtx sends a chat completion request with context support for cancellation and timeout. func (c *OpenAIClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + req.ApplyDefaults(&c.cfg) model := req.Model if model == "" { model = c.cfg.Model @@ -405,6 +430,9 @@ func (c *OpenAIClient) buildOpenAIParams(model string, req ChatRequest) openai.C if req.Temperature != nil { params.Temperature = openai.Float(*req.Temperature) } + if req.TopP != nil { + params.TopP = openai.Float(*req.TopP) + } return params } @@ -530,6 +558,7 @@ func NewAnthropicClient(cfg ClientConfig) *AnthropicClient { // CompletionsWithCtx sends a chat completion request with context support. func (c *AnthropicClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + req.ApplyDefaults(&c.cfg) model := req.Model if model == "" { model = c.cfg.Model @@ -660,6 +689,12 @@ func (c *AnthropicClient) buildAnthropicParams(model string, req ChatRequest) (a if req.Temperature != nil { params.Temperature = anthropic.Float(*req.Temperature) } + if req.TopP != nil { + params.TopP = anthropic.Float(*req.TopP) + } + if req.TopK != nil { + params.TopK = anthropic.Int(int64(*req.TopK)) + } return params, nil } diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 50db73c7..43dbcc72 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -569,3 +569,96 @@ func TestStripThinkTags(t *testing.T) { }) } } + +func TestApplyDefaults(t *testing.T) { + t.Run("nil config leaves request unchanged", func(t *testing.T) { + req := ChatRequest{Temperature: ptrFloat(0.7)} + req.ApplyDefaults(nil) + if req.Temperature == nil || *req.Temperature != 0.7 { + t.Error("ApplyDefaults(nil) should not clear existing Temperature") + } + }) + + t.Run("fills unset fields from config", func(t *testing.T) { + cfg := &ClientConfig{ + TopP: ptrFloat(0.9), + TopK: ptrInt(40), + Temperature: ptrFloat(0.5), + } + req := ChatRequest{} + req.ApplyDefaults(cfg) + if req.TopP == nil || *req.TopP != 0.9 { + t.Errorf("ApplyDefaults: TopP = %v, want 0.9", req.TopP) + } + if req.TopK == nil || *req.TopK != 40 { + t.Errorf("ApplyDefaults: TopK = %v, want 40", req.TopK) + } + if req.Temperature == nil || *req.Temperature != 0.5 { + t.Errorf("ApplyDefaults: Temperature = %v, want 0.5", req.Temperature) + } + }) + + t.Run("does not override already-set fields", func(t *testing.T) { + cfg := &ClientConfig{Temperature: ptrFloat(1.0)} + req := ChatRequest{Temperature: ptrFloat(0.3)} + req.ApplyDefaults(cfg) + if req.Temperature == nil || *req.Temperature != 0.3 { + t.Error("ApplyDefaults should not override existing Temperature") + } + }) +} + +func TestBuildOpenAIParams_LLMParams(t *testing.T) { + client := NewOpenAIClient(ClientConfig{URL: "https://api.example.com/v1"}) + + req := ChatRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + Temperature: ptrFloat(0.7), + TopP: ptrFloat(0.9), + } + + params := client.buildOpenAIParams("gpt-4", req) + + if params.Temperature.Value != 0.7 { + t.Errorf("Temperature = %v, want 0.7", params.Temperature.Value) + } + if params.TopP.Value != 0.9 { + t.Errorf("TopP = %v, want 0.9", params.TopP.Value) + } +} + +func TestBuildAnthropicParams_LLMParams(t *testing.T) { + client := NewAnthropicClient(ClientConfig{URL: "https://api.anthropic.com"}) + + req := ChatRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + Temperature: ptrFloat(0.5), + TopP: ptrFloat(0.8), + TopK: ptrInt(40), + } + + params, err := client.buildAnthropicParams("claude-sonnet-4-20250514", req) + if err != nil { + t.Fatalf("buildAnthropicParams: %v", err) + } + + if params.Temperature.Value != 0.5 { + t.Errorf("Temperature = %v, want 0.5", params.Temperature.Value) + } + if params.TopP.Value != 0.8 { + t.Errorf("TopP = %v, want 0.8", params.TopP.Value) + } + if params.TopK.Value != 40 { + t.Errorf("TopK = %v, want 40", params.TopK.Value) + } +} + +// ptrFloat returns a *float64 for the given value. +func ptrFloat(v float64) *float64 { + return &v +} + +// ptrInt returns a *int for the given value. +func ptrInt(v int) *int { + return &v +} diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 1c2d7813..f67a262d 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -20,6 +20,9 @@ type ResolvedEndpoint struct { Protocol string // "anthropic" or "openai" AuthHeader string // Anthropic auth header: "x-api-key" or "authorization" Source string // human-readable config source label + TopP *float64 // nucleus sampling parameter + TopK *int // top-k sampling parameter + Temperature *float64 // temperature parameter ExtraBody map[string]any // vendor-specific request body fields ExtraHeaders map[string]string // extra HTTP headers for the LLM request // Timeout is the per-request HTTP timeout; 0 means use the client default (5 min). @@ -193,6 +196,9 @@ type llmFileConfig struct { Model string `json:"model,omitempty"` UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"` } @@ -206,6 +212,9 @@ type providerEntryConfig struct { Models []string `json:"models,omitempty"` AuthHeader string `json:"auth_header,omitempty"` TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"` } @@ -366,6 +375,9 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, Protocol: protocol, AuthHeader: authHeader, Source: "provider:" + cfg.Provider, + TopP: entry.TopP, + TopK: entry.TopK, + Temperature: entry.Temperature, ExtraBody: extraBody, ExtraHeaders: extraHeaders, Timeout: timeout, @@ -409,7 +421,20 @@ func tryLegacyLlmConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, return ResolvedEndpoint{}, false, fmt.Errorf("OCR config file: %w", err) } - return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders, Timeout: timeout}, true, nil + return ResolvedEndpoint{ + URL: cfg.Llm.URL, + Token: cfg.Llm.AuthToken, + Model: model, + Protocol: protocol, + AuthHeader: authHeader, + Source: "OCR config file", + TopP: cfg.Llm.TopP, + TopK: cfg.Llm.TopK, + Temperature: cfg.Llm.Temperature, + ExtraBody: cfg.Llm.ExtraBody, + ExtraHeaders: cfg.Llm.ExtraHeaders, + Timeout: timeout, + }, true, nil } // tryCCEnv reads Claude Code environment variables. diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 5b2caceb..faa872b3 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -1446,3 +1446,76 @@ func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) { t.Errorf("error %q should mention OCR_LLM_TIMEOUT", err.Error()) } } + +func TestResolveEndpoint_ProviderLLMParams(t *testing.T) { + clearAllEnv(t) + + topP := 0.9 + topK := 50 + temp := 0.3 + + cfg := configFile{ + Provider: "anthropic", + Providers: map[string]providerEntryConfig{ + "anthropic": { + APIKey: "sk-ant-test", + Model: "claude-sonnet-4-6", + TopP: &topP, + TopK: &topK, + Temperature: &temp, + }, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + os.WriteFile(cfgPath, data, 0644) + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.TopP == nil || *ep.TopP != 0.9 { + t.Errorf("TopP = %v, want 0.9", ep.TopP) + } + if ep.TopK == nil || *ep.TopK != 50 { + t.Errorf("TopK = %v, want 50", ep.TopK) + } + if ep.Temperature == nil || *ep.Temperature != 0.3 { + t.Errorf("Temperature = %v, want 0.3", ep.Temperature) + } +} + +func TestResolveEndpoint_CustomProviderLLMParams(t *testing.T) { + clearAllEnv(t) + + topP := 0.95 + temp := 0.7 + + cfg := configFile{ + Provider: "my-gateway", + CustomProviders: map[string]providerEntryConfig{ + "my-gateway": { + URL: "https://my-gateway.example.com/v1", + Protocol: "openai", + APIKey: "test-key", + Model: "gpt-4", + TopP: &topP, + Temperature: &temp, + }, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + os.WriteFile(cfgPath, data, 0644) + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.TopP == nil || *ep.TopP != 0.95 { + t.Errorf("TopP = %v, want 0.95", ep.TopP) + } + if ep.Temperature == nil || *ep.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7", ep.Temperature) + } +} diff --git a/internal/llmloop/compression.go b/internal/llmloop/compression.go index 89f9f8ca..79052f4b 100644 --- a/internal/llmloop/compression.go +++ b/internal/llmloop/compression.go @@ -207,9 +207,12 @@ func (r *Runner) runCompression(ctx context.Context, msgs []llm.Message, filePat startTime := time.Now() resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: r.deps.Model, - Messages: compressionMsgs, - MaxTokens: r.deps.Template.MaxTokens, + Model: r.deps.Model, + Messages: compressionMsgs, + MaxTokens: r.deps.Template.MaxTokens, + Temperature: r.deps.Temperature, + TopP: r.deps.TopP, + TopK: r.deps.TopK, }) duration := time.Since(startTime) diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index d206a783..f007c008 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -30,6 +30,10 @@ type Deps struct { CommentCollector *tool.CommentCollector CommentWorkerPool *CommentWorkerPool Session *session.SessionHistory + // CLI-level overrides for LLM sampling parameters (nil = use provider config). + TopP *float64 + TopK *int + Temperature *float64 // DiffLookup is consulted by the code_comment tool path to resolve // line numbers against the file's diff (or against full file content // in scan mode — scan adapters return a synthetic Diff whose @@ -164,10 +168,13 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath startTime := time.Now() resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: r.deps.Model, - Messages: messages, - Tools: r.deps.MainToolDefs, - MaxTokens: r.deps.Template.MaxTokens, + Model: r.deps.Model, + Messages: messages, + Tools: r.deps.MainToolDefs, + MaxTokens: r.deps.Template.MaxTokens, + Temperature: r.deps.Temperature, + TopP: r.deps.TopP, + TopK: r.deps.TopK, }) duration := time.Since(startTime) if err != nil { diff --git a/internal/scan/agent.go b/internal/scan/agent.go index d9095e91..5be0f960 100644 --- a/internal/scan/agent.go +++ b/internal/scan/agent.go @@ -67,6 +67,10 @@ type Args struct { // batches are dispatched. 0 = unlimited. Set via --max-tokens-budget // or ScanTemplate.MaxTokensBudget. MaxTokensBudget int64 + // CLI-level overrides for LLM sampling parameters (nil = use provider config). + TopP *float64 + TopK *int + Temperature *float64 } // planEnabled / dedupEnabled / summaryEnabled report whether each optional @@ -129,6 +133,9 @@ func NewAgent(args Args) *Agent { CommentCollector: args.CommentCollector, CommentWorkerPool: args.CommentWorkerPool, Session: args.Session, + TopP: args.TopP, + TopK: args.TopK, + Temperature: args.Temperature, // DiffLookup returns a synthetic Diff so the code_comment tool's // line-number resolver (resolveFromFileContent) can match against // the full file content of the scanned file. @@ -586,9 +593,12 @@ func (a *Agent) maybeRunPlan(ctx context.Context, it model.ScanItem, rule string startTime := time.Now() resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: a.args.Model, - Messages: messages, - MaxTokens: a.args.Template.MaxTokens, + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.MaxTokens, + Temperature: a.args.Temperature, + TopP: a.args.TopP, + TopK: a.args.TopK, }) if err != nil { rec.SetError(err, time.Since(startTime)) @@ -639,9 +649,12 @@ func (a *Agent) maybeRunProjectSummary(ctx context.Context, comments []model.Llm startTime := time.Now() resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: a.args.Model, - Messages: messages, - MaxTokens: a.args.Template.MaxTokens, + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.MaxTokens, + Temperature: a.args.Temperature, + TopP: a.args.TopP, + TopK: a.args.TopK, }) if err != nil { rec.SetError(err, time.Since(startTime)) @@ -714,9 +727,12 @@ func (a *Agent) maybeRunDedup(ctx context.Context, batchIdx, batchStart int) { startTime := time.Now() resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ - Model: a.args.Model, - Messages: messages, - MaxTokens: a.args.Template.MaxTokens, + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.MaxTokens, + Temperature: a.args.Temperature, + TopP: a.args.TopP, + TopK: a.args.TopK, }) if err != nil { rec.SetError(err, time.Since(startTime))