mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-11 09:44:46 +00:00
* feat(llm): add OpenAI Responses API support Add `openai-responses` as a third LLM protocol alongside `anthropic` and `openai-chat-completions`, enabling code review via the OpenAI Responses API (/v1/responses) for GPT-5.x / o-series models. Protocol naming refactor (backward-compatible): - Canonicalize "openai" -> "openai-chat-completions" (alias still accepted) - Add NormalizeProtocol / ValidateProtocol / IsAnthropicProtocol helpers - Registry uses canonical constants; resolver normalizes everywhere New OpenAIResponsesClient (stateless replay, per DESIGN_STATE_CACHE_PHASE.md): - system messages -> Instructions; tool calls -> function_call items keyed by CallID so the agent loop pairs results correctly - store=false (privacy); prompt_cache_key = sha256(instructions)[:32] - Phase fields (commentary/final_answer) dropped with TODO for gpt-5.3-codex+ Config plumbing: - llm.protocol field + OCR_LLM_PROTOCOL env (priority over use_anthropic / OCR_USE_ANTHROPIC); TUI exposes all three protocols in Custom & Manual - anthropic-vertex rejected with friendly "not yet implemented" message Docs: protocol reference, config examples, env var table, and Responses API notes (store=false caching caveat, cache key derivation, Phase TODO) updated across en/zh-CN/ko-KR/ja-JP/ru-RU READMEs. * refactor(llm): switch PromptCacheKey to precomputed scheme via ChatRequest.CacheKey Replace per-turn sha256 computation inside buildResponsesParams with a precomputed cache key that callers compute once per session and pass through ChatRequest.CacheKey (json:"-"). The key now incorporates the first user message alongside instructions, so different files under review land in distinct cache buckets — the previous instructions-only key was identical across all files. Changes: - ChatRequest gains CacheKey string field (json:"-", zero impact on Chat Completions / Anthropic clients which never read it) - New llm.ComputeCacheKey helper: sha256(instructions + "\x00" + firstUser)[:32] - responses_client.go: reads req.CacheKey directly, removes promptCacheKey function and first-user-message scanning - loop.go: RunPerFile computes cacheKey once before the loop, reuses every turn - All 8 remaining call sites (agent, scan, relocation, compression, llm_cmd) compute once at request construction - Update PLAN_RESPONSES_SUPPORT.md and DESIGN_STATE_CACHE_PHASE.md to reflect the precomputed scheme - Update tests: passthrough tests for client, dedicated TestComputeCacheKey * refactor(llm): use canonical protocol name "openai" and UUID-based session ID for cache key Two changes to maximize backward compatibility and simplify the design: 1. Protocol naming: revert ProtocolOpenAIChatCompletions value from "openai-chat-completions" back to "openai". Old config files with protocol: "openai" are now identical to what new configs write — zero behavioral difference. The alias direction in NormalizeProtocol is reversed: "openai-chat-completions" -> "openai" (for configs written during this branch's testing phase only). 2. Cache key: replace content-based sha256 hash (ComputeCacheKey) with a random UUID session ID. The agent loop generates one UUID per file in RunPerFile and passes it via ChatRequest.SessionID; the Responses client uses it as prompt_cache_key. Single-turn call sites no longer set a cache key (no multi-turn caching benefit). This removes the need to scan messages or compute hashes, and eliminates collision risk between files with similar content. ChatRequest.CacheKey is renamed to SessionID to reflect its actual semantic — a per-session identifier that the Responses client repurposes as prompt_cache_key. Also updates PLAN_RESPONSES_SUPPORT.md, all 5 README translations, test expectations, and promotes google/uuid to a direct dependency. * refactor(llm): remove IsAnthropicProtocol helper and anthropic-vertex special case * refactor(llm): remove openai-chat-completions branch-internal alias * docs: remove DESIGN_STATE_CACHE_PHASE and PLAN_RESPONSES_SUPPORT design notes * fix(llm): address code review findings on Responses API support - provider_cmd: clear stale use_anthropic when switching to openai-responses - resolver: validate preset protocol with ValidateProtocol for consistency - responses_client: swap usage mapping to resolveUsage-first (matches OpenAIClient) - responses_client: map failed/cancelled statuses to 'error' finish reason - usage_resolver: add Responses API field paths (input_tokens, output_tokens, input_tokens_details.cached_tokens) - add tests for all four fixes * fix(llm): mirror use_anthropic when setting llm.protocol - config_cmd: 'ocr config set llm.protocol' now mirrors use_anthropic (anthropic -> true, OpenAI family -> false) for backward compat with older binaries that predate llm.protocol - provider_cmd: openai-responses now sets use_anthropic=false instead of nil, so older binaries fall back to the OpenAI family rather than wrongly defaulting to anthropic - update tests for both write paths * fix(llm): mirror protocol when setting llm.use_anthropic - config_cmd: 'ocr config set llm.use_anthropic' now mirrors protocol (true -> anthropic, false -> openai) so the two fields never disagree, matching the reverse llm.protocol mirroring added previously - without this, setting use_anthropic=true while protocol=openai-responses left a contradictory config that misled older binaries into using the anthropic protocol against an OpenAI endpoint - extend tests to cover both values and stale-protocol overwrite * docs(llm): fix NormalizeProtocol comment to match lowercasing behavior The comment claimed unknown values are 'returned unchanged', but the default branch lowercases and trims them (corroborated by the 'gRPC -> grpc' test). Update the wording to describe the actual behavior so callers aren't misled about round-trip fidelity. * docs(llm): drop OpenAI Responses API implementation notes from READMEs * fix(llm): address code review findings on Responses API support - config: preserve openai-responses when setting llm.use_anthropic=false (only mirror to openai when protocol is unset or a legacy anthropic/openai) - config: add Protocol values guidance to unknown-key error message - protocol: extract normalized local var in NormalizeProtocol - responses_client: align SDK base URL trimming with NewOpenAIClient - responses_client: drop unused test-only sdkBaseURL method * fix(llm): use protocol constants consistently - providers: edenai now uses ProtocolOpenAIChatCompletions like the rest of the registry instead of the "openai" string literal - provider_cmd: print the normalized protocol variable (what is actually saved) instead of the raw TUI value * fix(llm): drop stream key and surface non-completed status in Responses client Address two PR review comments on OpenAI Responses API support: 1. extra_body.stream=true was forwarded to Responses.New, making the API return SSE while the SDK expects JSON and breaking every call. Skip the 'stream' key (like OpenAIClient treats it as a non-forwarded key) while still forwarding other extra_body entries. 2. The Responses API returns HTTP 200 even for failed/cancelled (terminal) and queued/in_progress (background) states, so the SDK reports nil error. Surface these as real errors so callers branching on err != nil (ocr llm test, review loop) fail instead of treating a dead response as success. Add table-driven tests covering both fixes.
343 lines
12 KiB
Go
343 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// --- custom flag set that supports short flags (-c, -f etc.) ---
|
|
|
|
type ocrFlagSet struct {
|
|
fs *flag.FlagSet
|
|
shortMap map[string]string // maps short key "c" -> full name "commit"
|
|
showHelp bool
|
|
}
|
|
|
|
func newOcrFlagSet(name string) *ocrFlagSet {
|
|
return &ocrFlagSet{
|
|
fs: flag.NewFlagSet(name, flag.ContinueOnError),
|
|
shortMap: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// StringVarP registers --name with optional short form -s.
|
|
func (a *ocrFlagSet) StringVarP(p *string, name, shorthand string, value, usage string) {
|
|
suffix := ""
|
|
if shorthand != "" {
|
|
a.shortMap[shorthand] = name
|
|
suffix = fmt.Sprintf(" (shorthand: -%s)", shorthand)
|
|
}
|
|
a.fs.StringVar(p, name, value, usage+suffix)
|
|
}
|
|
|
|
// BoolVarP registers --name with optional short form -s.
|
|
func (a *ocrFlagSet) BoolVarP(p *bool, name, shorthand string, value bool, usage string) {
|
|
suffix := ""
|
|
if shorthand != "" {
|
|
a.shortMap[shorthand] = name
|
|
suffix = fmt.Sprintf(" (shorthand: -%s)", shorthand)
|
|
}
|
|
a.fs.BoolVar(p, name, value, usage+suffix)
|
|
}
|
|
|
|
func (a *ocrFlagSet) StringVar(p *string, name string, value string, usage string) {
|
|
a.fs.StringVar(p, name, value, usage)
|
|
}
|
|
|
|
func (a *ocrFlagSet) BoolVar(p *bool, name string, value bool, usage string) {
|
|
a.fs.BoolVar(p, name, value, usage)
|
|
}
|
|
|
|
func (a *ocrFlagSet) IntVar(p *int, name string, value int, usage string) {
|
|
a.fs.IntVar(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)
|
|
}
|
|
|
|
func (a *ocrFlagSet) PrintDefaults() {
|
|
a.fs.PrintDefaults()
|
|
}
|
|
|
|
func (a *ocrFlagSet) Parse(arguments []string) error {
|
|
expanded := expandShortFlags(arguments, a.shortMap)
|
|
|
|
for _, arg := range expanded {
|
|
if arg == "-h" || arg == "--help" {
|
|
a.showHelp = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return a.fs.Parse(expanded)
|
|
}
|
|
|
|
// expandShortFlags replaces standalone -X args with their long equivalents.
|
|
// Only triggers when the arg is exactly -N (single char after dash).
|
|
func expandShortFlags(args []string, shortMap map[string]string) []string {
|
|
out := make([]string, 0, len(args))
|
|
for _, arg := range args {
|
|
if len(arg) == 2 && arg[0] == '-' && arg[1] != '-' {
|
|
key := string(arg[1])
|
|
if full, ok := shortMap[key]; ok {
|
|
out = append(out, "--"+full)
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, arg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- review subcommand options ---
|
|
|
|
type reviewOptions struct {
|
|
toolConfigPath string
|
|
rulePath string
|
|
repoDir string
|
|
from string
|
|
to string
|
|
commit string
|
|
resume string
|
|
excludes string // --exclude: comma-separated gitignore-style patterns
|
|
outputFormat string
|
|
audience string // --audience: "human" (default) or "agent"
|
|
background string // --background: optional requirement context
|
|
backgroundFile string // --background-file: path to a Markdown file used as background
|
|
model string // --model: override resolved LLM model for this review
|
|
concurrency int
|
|
perFileTimeout int
|
|
maxTools int
|
|
maxGitProcs int
|
|
preview bool
|
|
showHelp bool
|
|
}
|
|
|
|
func parseReviewFlags(args []string) (reviewOptions, error) {
|
|
a := newOcrFlagSet("ocr review")
|
|
|
|
opts := reviewOptions{}
|
|
|
|
a.StringVar(&opts.toolConfigPath, "tools", "", "path to JSON tools config file (default: embedded)")
|
|
a.StringVar(&opts.rulePath, "rule", "", "path to JSON file with system review rules")
|
|
a.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')")
|
|
a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')")
|
|
a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)")
|
|
a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id")
|
|
a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
|
|
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
|
|
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
|
|
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
|
|
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.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)")
|
|
a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)")
|
|
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")
|
|
|
|
if err := a.Parse(args); err != nil {
|
|
return opts, fmt.Errorf("parse flags: %w", err)
|
|
}
|
|
|
|
opts.showHelp = a.showHelp
|
|
if opts.showHelp {
|
|
return opts, nil
|
|
}
|
|
|
|
modeCount := 0
|
|
if opts.from != "" || opts.to != "" {
|
|
modeCount++
|
|
}
|
|
if opts.commit != "" {
|
|
modeCount++
|
|
}
|
|
// modeCount == 0 → workspace mode (no error, allowed)
|
|
if modeCount > 1 {
|
|
return opts, fmt.Errorf("only one review mode allowed (--from/--to or --commit)")
|
|
}
|
|
if opts.from != "" && opts.to == "" {
|
|
return opts, fmt.Errorf("--to is required when --from is specified")
|
|
}
|
|
if opts.to != "" && opts.from == "" {
|
|
return opts, fmt.Errorf("--from is required when --to is specified")
|
|
}
|
|
if opts.preview && opts.resume != "" {
|
|
return opts, fmt.Errorf("--preview and --resume cannot be used together")
|
|
}
|
|
|
|
switch opts.audience {
|
|
case "human", "agent":
|
|
default:
|
|
return opts, fmt.Errorf("invalid --audience value %q: must be 'human' or 'agent'", opts.audience)
|
|
}
|
|
|
|
const minMaxTools = 10
|
|
if opts.maxTools < 0 {
|
|
return opts, fmt.Errorf("--max-tools must be a non-negative integer (0 means use template default)")
|
|
}
|
|
if opts.maxTools > 0 && opts.maxTools < minMaxTools {
|
|
fmt.Fprintf(os.Stderr, "[ocr] --max-tools %d is below minimum %d, using %d\n", opts.maxTools, minMaxTools, minMaxTools)
|
|
opts.maxTools = minMaxTools
|
|
}
|
|
|
|
if opts.maxGitProcs < 0 {
|
|
return opts, fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)")
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func printReviewUsage() {
|
|
fmt.Println(`OpenCodeReview - AI-Powered Code Review CLI
|
|
|
|
Usage:
|
|
ocr review [flags]
|
|
ocr r [flags] (alias)
|
|
|
|
Examples:
|
|
# Review staged + unstaged + untracked changes in current workspace
|
|
ocr review
|
|
|
|
# Review a branch against its base (merge-base mode)
|
|
ocr review --from master --to dev-ref
|
|
|
|
# Review a specific commit
|
|
ocr review --commit abc123
|
|
ocr review -c abc123
|
|
|
|
# Resume a previous range review
|
|
ocr review --from master --to dev-ref --resume <session-id>
|
|
|
|
# Output JSON format
|
|
ocr review --format json
|
|
ocr review -f json
|
|
|
|
# Agent mode (summary only, no progress lines)
|
|
ocr review --audience agent
|
|
|
|
# Preview which files will be reviewed
|
|
ocr review --preview
|
|
ocr review -c abc123 -p
|
|
|
|
# Provide requirement/business context inline, from a Markdown file, or both
|
|
ocr review --background "Adding rate limiting to the login API"
|
|
ocr review --background-file ./docs/requirements.md
|
|
ocr review --background "Focus on auth" --background-file ./docs/requirements.md
|
|
|
|
Flags:
|
|
--audience string output audience: human (show progress) or agent (summary only) (default "human")
|
|
-b, --background string optional requirement/business context for the review
|
|
-B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set)
|
|
-c, --commit string single commit hash or tag to review (vs its parent)
|
|
-f, --format string output format: text or json (default "text")
|
|
--concurrency int max concurrent file reviews (default 8)
|
|
--max-git-procs int max concurrent git subprocesses (default 16)
|
|
--from string source ref to start diff from (e.g., 'main')
|
|
--max-tools int max tool call rounds per file (0 = template default; min 10)
|
|
--model string override LLM model for this review (e.g., claude-opus-4-6)
|
|
-p, --preview preview which files will be reviewed without running the LLM
|
|
--repo string root directory of the git repository (default: current dir)
|
|
--resume string resume from a previous review session id
|
|
--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)`)
|
|
}
|
|
|
|
// --- config subcommand ---
|
|
|
|
type configAction struct {
|
|
subCmd string // "set", "unset"
|
|
key string
|
|
value string
|
|
}
|
|
|
|
func parseConfigArgs(args []string) (configAction, error) {
|
|
if len(args) == 0 {
|
|
return configAction{}, fmt.Errorf("usage: ocr config set <key> <value>\ne.g., ocr config set llm.model claude-opus-4-6")
|
|
}
|
|
|
|
subCmd := args[0]
|
|
switch subCmd {
|
|
case "set":
|
|
if len(args) < 3 {
|
|
return configAction{}, fmt.Errorf("usage: ocr config set <key> <value>\ne.g., ocr config set llm.model claude-opus-4-6")
|
|
}
|
|
return configAction{
|
|
subCmd: "set",
|
|
key: args[1],
|
|
value: args[2],
|
|
}, nil
|
|
case "unset":
|
|
if len(args) < 2 {
|
|
return configAction{}, fmt.Errorf("usage: ocr config unset custom_providers.<name>\ne.g., ocr config unset custom_providers.my-gateway")
|
|
}
|
|
return configAction{
|
|
subCmd: "unset",
|
|
key: args[1],
|
|
}, nil
|
|
default:
|
|
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set, unset, provider, model", subCmd)
|
|
}
|
|
}
|
|
|
|
func printConfigUsage() {
|
|
fmt.Println(`Configuration management.
|
|
|
|
Usage:
|
|
ocr config set <key> <value>
|
|
ocr config unset custom_providers.<name> Delete a custom provider
|
|
ocr config unset mcp_servers.<name> Delete an MCP server
|
|
ocr config provider Interactive provider setup
|
|
ocr config model Interactive model selection
|
|
|
|
Examples:
|
|
# Provider setup (interactive)
|
|
ocr config provider
|
|
ocr config model
|
|
|
|
# Provider setup (non-interactive)
|
|
ocr config set provider anthropic
|
|
ocr config set model claude-opus-4-6
|
|
# Set API key via environment variable (recommended) or config:
|
|
# export ANTHROPIC_API_KEY=sk-ant-xxx
|
|
ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY"
|
|
|
|
# Custom provider
|
|
ocr config set provider my-gateway
|
|
ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1
|
|
ocr config set custom_providers.my-gateway.protocol openai
|
|
ocr config set custom_providers.my-gateway.model llama-3-70b
|
|
ocr config set custom_providers.my-gateway.models '["llama-3-70b","llama-3-8b"]'
|
|
ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY"
|
|
|
|
# Delete a custom provider
|
|
ocr config unset custom_providers.my-gateway
|
|
|
|
# MCP server configuration (stdio transport)
|
|
ocr config set mcp_servers.codegraph.command npx
|
|
ocr config set mcp_servers.codegraph.args '["-y","@anthropic/codegraph-mcp"]'
|
|
ocr config set mcp_servers.codegraph.env '["CODEGRAPH_TOKEN=xxx"]'
|
|
|
|
# Delete an MCP server
|
|
ocr config unset mcp_servers.codegraph
|
|
|
|
# Legacy endpoint configuration
|
|
ocr config set llm.url https://xx/v1/openai/chat/completions
|
|
ocr config set llm.auth_token xxxxxxxxxx
|
|
ocr config set llm.auth_header x-api-key
|
|
ocr config set llm.model claude-opus-4-6
|
|
ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}'
|
|
ocr config set language English
|
|
ocr config set telemetry.enabled true
|
|
|
|
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
|
|
Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers
|
|
Protocol values: anthropic, openai, openai-responses
|
|
MCP server fields: command, args, env, tools, setup`)
|
|
}
|