diff --git a/apps/openant-cli/cmd/analyze.go b/apps/openant-cli/cmd/analyze.go index 986213b..9bb3ff8 100644 --- a/apps/openant-cli/cmd/analyze.go +++ b/apps/openant-cli/cmd/analyze.go @@ -13,7 +13,7 @@ import ( var analyzeCmd = &cobra.Command{ Use: "analyze [dataset-path]", Short: "Run vulnerability analysis on parsed data", - Long: `Analyze runs Claude-powered Stage 1 vulnerability detection on a parsed dataset. + Long: `Analyze runs LLM-powered Stage 1 vulnerability detection on a parsed dataset. With --verify, it chains into Stage 2 attacker simulation automatically. For standalone Stage 2, use the verify command instead. @@ -124,7 +124,7 @@ func runAnalyze(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--backoff", fmt.Sprintf("%d", analyzeBackoff)) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnvRequired()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/cmd/buildoutput.go b/apps/openant-cli/cmd/buildoutput.go index fbb7472..348ed8e 100644 --- a/apps/openant-cli/cmd/buildoutput.go +++ b/apps/openant-cli/cmd/buildoutput.go @@ -98,7 +98,7 @@ func runBuildOutput(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--processing-level", buildOutputProcessingLevel) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnv()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/cmd/config.go b/apps/openant-cli/cmd/config.go index 05de464..84708b4 100644 --- a/apps/openant-cli/cmd/config.go +++ b/apps/openant-cli/cmd/config.go @@ -19,9 +19,12 @@ var configCmd = &cobra.Command{ Configuration is stored in ~/.config/openant/config.json. Examples: - openant config set api-key Set your Anthropic API key (interactive) + openant config set api-key Set your API key (interactive) + openant config set base-url Set LLM endpoint for local AI + openant config set opus-model Set model for heavy analysis + openant config set sonnet-model Set model for lighter tasks openant config show View current configuration - openant config unset api-key Remove your API key + openant config unset base-url Remove a setting openant config path Print the config file path`, } @@ -31,10 +34,14 @@ var configSetCmd = &cobra.Command{ Long: `Set a configuration value. For sensitive values like api-key, the value is read from stdin (not echoed) to avoid shell history exposure. -Supported keys: api-key, default-model +Supported keys: api-key, base-url, default-model, opus-model, sonnet-model, verify-ssl Examples: openant config set api-key Interactive prompt (recommended) + openant config set base-url Set LLM endpoint (e.g. http://localhost:8080) + openant config set opus-model Set model name for heavy analysis + openant config set sonnet-model Set model name for lighter tasks + openant config set verify-ssl Enable/disable SSL certificate verification echo "sk-ant-..." | openant config set api-key --stdin Piped input`, Args: cobra.ExactArgs(1), Run: runConfigSet, @@ -126,8 +133,97 @@ func runConfigSet(cmd *cobra.Command, args []string) { cfg.DefaultModel = value + case "base-url": + if configStdin { + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } else { + fmt.Fprint(os.Stderr, "Enter LLM API base URL (e.g. http://localhost:8080): ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } + + if value == "" { + output.PrintError("No value provided") + os.Exit(1) + } + + cfg.BaseURL = value + + case "opus-model": + if configStdin { + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } else { + fmt.Fprint(os.Stderr, "Enter opus model name (heavy analysis, e.g. qwen3:32b): ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } + + if value == "" { + output.PrintError("No value provided") + os.Exit(1) + } + + cfg.OpusModel = value + + case "sonnet-model": + if configStdin { + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } else { + fmt.Fprint(os.Stderr, "Enter sonnet model name (lighter tasks, e.g. qwen3:8b): ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } + + if value == "" { + output.PrintError("No value provided") + os.Exit(1) + } + + cfg.SonnetModel = value + + case "verify-ssl": + if configStdin { + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } else { + fmt.Fprint(os.Stderr, "Verify SSL certificates? (true/false): ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + value = strings.TrimSpace(scanner.Text()) + } + } + + switch strings.ToLower(value) { + case "true", "yes", "1": + v := true + cfg.VerifySSL = &v + case "false", "no", "0": + v := false + cfg.VerifySSL = &v + default: + output.PrintError("Value must be true or false") + os.Exit(1) + } + default: - output.PrintError(fmt.Sprintf("Unknown config key: %s\nSupported keys: api-key, default-model", key)) + output.PrintError(fmt.Sprintf("Unknown config key: %s\nSupported keys: api-key, base-url, default-model, opus-model, sonnet-model, verify-ssl", key)) os.Exit(1) } @@ -151,9 +247,21 @@ func runConfigShow(cmd *cobra.Command, args []string) { output.PrintHeader("Configuration") output.PrintKeyValue("api_key", config.MaskKey(cfg.APIKey)) + if cfg.BaseURL != "" { + output.PrintKeyValue("base_url", cfg.BaseURL) + } if cfg.DefaultModel != "" { output.PrintKeyValue("default_model", cfg.DefaultModel) } + if cfg.OpusModel != "" { + output.PrintKeyValue("opus_model", cfg.OpusModel) + } + if cfg.SonnetModel != "" { + output.PrintKeyValue("sonnet_model", cfg.SonnetModel) + } + if cfg.VerifySSL != nil { + output.PrintKeyValue("verify_ssl", fmt.Sprintf("%v", *cfg.VerifySSL)) + } if cfg.ActiveProject != "" { output.PrintKeyValue("active_project", cfg.ActiveProject) } @@ -173,10 +281,18 @@ func runConfigUnset(cmd *cobra.Command, args []string) { switch key { case "api-key": cfg.APIKey = "" + case "base-url": + cfg.BaseURL = "" case "default-model": cfg.DefaultModel = "" + case "opus-model": + cfg.OpusModel = "" + case "sonnet-model": + cfg.SonnetModel = "" + case "verify-ssl": + cfg.VerifySSL = nil default: - output.PrintError(fmt.Sprintf("Unknown config key: %s\nSupported keys: api-key, default-model", key)) + output.PrintError(fmt.Sprintf("Unknown config key: %s\nSupported keys: api-key, base-url, default-model, opus-model, sonnet-model, verify-ssl", key)) os.Exit(1) } diff --git a/apps/openant-cli/cmd/dynamictest.go b/apps/openant-cli/cmd/dynamictest.go index 1d19297..233b67c 100644 --- a/apps/openant-cli/cmd/dynamictest.go +++ b/apps/openant-cli/cmd/dynamictest.go @@ -86,7 +86,7 @@ func runDynamicTest(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--repo-path", ctx.RepoPath) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnvRequired()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/cmd/enhance.go b/apps/openant-cli/cmd/enhance.go index 5381213..be4e470 100644 --- a/apps/openant-cli/cmd/enhance.go +++ b/apps/openant-cli/cmd/enhance.go @@ -105,7 +105,7 @@ func runEnhance(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--backoff", fmt.Sprintf("%d", enhanceBackoff)) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnvRequired()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/cmd/parse.go b/apps/openant-cli/cmd/parse.go index 988801f..dedc92f 100644 --- a/apps/openant-cli/cmd/parse.go +++ b/apps/openant-cli/cmd/parse.go @@ -106,7 +106,7 @@ func runParse(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--diff-manifest", manifestPath) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnv()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/cmd/report.go b/apps/openant-cli/cmd/report.go index d2b34b7..fd3c16d 100644 --- a/apps/openant-cli/cmd/report.go +++ b/apps/openant-cli/cmd/report.go @@ -217,7 +217,7 @@ func runReport(cmd *cobra.Command, args []string) { // Other formats delegate to Python pyArgs := buildReportArgs(resultsPath, fmt) - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnv()) if err != nil { output.PrintError(fmt + ": " + err.Error()) exitCode = 2 @@ -312,7 +312,7 @@ func runHTMLReport(rt *python.RuntimeInfo, resultsPath string, outputPath string pyArgs = append(pyArgs, "--dataset", reportDataset) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnv()) if err != nil { return fmt.Errorf("report-data failed: %w", err) } diff --git a/apps/openant-cli/cmd/root.go b/apps/openant-cli/cmd/root.go index 334dc9a..a97a2d8 100644 --- a/apps/openant-cli/cmd/root.go +++ b/apps/openant-cli/cmd/root.go @@ -24,9 +24,12 @@ var ( var rootCmd = &cobra.Command{ Use: "openant", Short: "LLM-powered static analysis security testing", - Long: `OpenAnt is a two-stage SAST tool that uses Claude to find real vulnerabilities + Long: `OpenAnt is a two-stage SAST tool that uses LLMs to find real vulnerabilities in Python, JavaScript, Go, and C/C++ codebases. +Works with Anthropic's API or any compatible local AI server (llama-swap, +llama-server, vLLM, LM Studio, etc.). + Stage 1: Detect potential vulnerabilities via code analysis Stage 2: Simulate an attacker to eliminate false positives @@ -40,7 +43,7 @@ Commands: build-output Assemble pipeline_output.json from verified results dynamic-test Docker-isolated exploit testing report Generate reports from analysis results - config Manage CLI configuration (API key, etc.)`, + config Manage CLI configuration (API key, endpoint, models)`, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -65,17 +68,57 @@ func requireAPIKey() string { } fmt.Fprintln(os.Stderr, "Error: No API key configured.") fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Run: openant set-api-key ") + fmt.Fprintln(os.Stderr, "Run: openant config set api-key") fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "You can get an API key at https://console.anthropic.com/settings/keys") + fmt.Fprintln(os.Stderr, "For local AI (llama-swap, llama-server, etc.), any value works:") + fmt.Fprintln(os.Stderr, " openant config set api-key (enter 'not-needed')") + fmt.Fprintln(os.Stderr, " openant config set base-url (enter your server URL)") os.Exit(2) return "" // unreachable } +// llmEnv builds the environment variable map passed to the Python subprocess. +// It injects the API key, base URL, and model names from the config file +// so the Python core can connect to a local AI server or Anthropic's API. +func llmEnv() map[string]string { + env := map[string]string{} + + key := resolvedAPIKey() + if key != "" { + env["ANTHROPIC_API_KEY"] = key + } + + cfg, err := config.Load() + if err != nil { + return env + } + + if cfg.BaseURL != "" { + env["ANTHROPIC_BASE_URL"] = cfg.BaseURL + } + if cfg.OpusModel != "" { + env["OPENANT_OPUS_MODEL"] = cfg.OpusModel + } + if cfg.SonnetModel != "" { + env["OPENANT_SONNET_MODEL"] = cfg.SonnetModel + } + if cfg.VerifySSL != nil && !*cfg.VerifySSL { + env["OPENANT_VERIFY_SSL"] = "false" + } + + return env +} + +// llmEnvRequired is like llmEnv but exits if no API key is configured. +func llmEnvRequired() map[string]string { + requireAPIKey() // exits if missing + return llmEnv() +} + func init() { rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output raw JSON (machine-readable)") rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Suppress progress output") - rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Anthropic API key (overrides config)") + rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "LLM API key (overrides config)") rootCmd.PersistentFlags().StringVarP(&projectFlag, "project", "p", "", "Project to use (overrides active project, e.g. grafana/grafana)") rootCmd.AddCommand(initCmd) diff --git a/apps/openant-cli/cmd/scan.go b/apps/openant-cli/cmd/scan.go index 2a646b5..0ec27b1 100644 --- a/apps/openant-cli/cmd/scan.go +++ b/apps/openant-cli/cmd/scan.go @@ -212,7 +212,7 @@ func runScan(cmd *cobra.Command, args []string) { } } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnvRequired()) if err != nil { finalizeScanMetaIfProject(ctx, config.ScanStatusFailed) output.PrintError(err.Error()) diff --git a/apps/openant-cli/cmd/setapikey.go b/apps/openant-cli/cmd/setapikey.go index 14194a9..99f2f86 100644 --- a/apps/openant-cli/cmd/setapikey.go +++ b/apps/openant-cli/cmd/setapikey.go @@ -40,17 +40,19 @@ func validateAPIKey(key string) error { var setAPIKeyCmd = &cobra.Command{ Use: "set-api-key ", - Short: "Save your Anthropic API key", - Long: `Save your Anthropic API key to the OpenAnt config file. + Short: "Save your LLM API key", + Long: `Save your LLM API key to the OpenAnt config file. The key is stored in ~/.config/openant/config.json with restricted permissions (0600). This is required before running enhance, analyze, verify, or scan. -Get an API key at https://console.anthropic.com/settings/keys +For Anthropic: get a key at https://console.anthropic.com/settings/keys +For local AI (llama-swap, etc.): use any non-empty value (e.g. "not-needed") Examples: - openant set-api-key sk-ant-api03-...`, + openant set-api-key sk-ant-api03-... Anthropic key + openant set-api-key not-needed Local AI (no real key needed)`, Args: cobra.ExactArgs(1), Run: runSetAPIKey, } @@ -62,23 +64,26 @@ func runSetAPIKey(cmd *cobra.Command, args []string) { os.Exit(1) } - // Validate against Anthropic BEFORE saving — a bad key should never - // be persisted, otherwise `openant scan` silently produces zero results - // that look like a clean repo. - fmt.Fprintf(os.Stderr, "Validating API key with Anthropic... ") - if err := validateAPIKey(key); err != nil { - fmt.Fprintf(os.Stderr, "\n") - output.PrintError(err.Error()) - os.Exit(1) - } - fmt.Fprintf(os.Stderr, "OK\n") - cfg, err := config.Load() if err != nil { output.PrintError(err.Error()) os.Exit(1) } + // Only validate against Anthropic if no custom base URL is configured. + // With a local AI server the Anthropic validation endpoint is irrelevant. + if cfg.BaseURL == "" { + fmt.Fprintf(os.Stderr, "Validating API key with Anthropic... ") + if err := validateAPIKey(key); err != nil { + fmt.Fprintf(os.Stderr, "\n") + output.PrintError(err.Error()) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "OK\n") + } else { + fmt.Fprintf(os.Stderr, "Custom base URL configured — skipping Anthropic validation\n") + } + cfg.APIKey = key if err := config.Save(cfg); err != nil { diff --git a/apps/openant-cli/cmd/verify.go b/apps/openant-cli/cmd/verify.go index cad9b8a..18f9cf7 100644 --- a/apps/openant-cli/cmd/verify.go +++ b/apps/openant-cli/cmd/verify.go @@ -107,7 +107,7 @@ func runVerify(cmd *cobra.Command, args []string) { pyArgs = append(pyArgs, "--backoff", fmt.Sprintf("%d", verifyBackoff)) } - result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey()) + result, err := python.Invoke(rt.Path, pyArgs, "", quiet, llmEnvRequired()) if err != nil { output.PrintError(err.Error()) os.Exit(2) diff --git a/apps/openant-cli/internal/checkpoint/checkpoint.go b/apps/openant-cli/internal/checkpoint/checkpoint.go index 18ff59b..c338870 100644 --- a/apps/openant-cli/internal/checkpoint/checkpoint.go +++ b/apps/openant-cli/internal/checkpoint/checkpoint.go @@ -65,7 +65,7 @@ func DetectViaPython(pythonPath, scanDir, stepName string) *Info { } // Call Python for accurate counts - result, err := python.Invoke(pythonPath, []string{"checkpoint-status", dir}, "", true, "") + result, err := python.Invoke(pythonPath, []string{"checkpoint-status", dir}, "", true, nil) if err != nil || result.Envelope.Status != "success" { // Python failed — fall back to simple file count return DetectFallback(scanDir, stepName) diff --git a/apps/openant-cli/internal/config/config.go b/apps/openant-cli/internal/config/config.go index 0af8f52..15cec48 100644 --- a/apps/openant-cli/internal/config/config.go +++ b/apps/openant-cli/internal/config/config.go @@ -17,7 +17,11 @@ import ( // Config holds the persistent CLI configuration. type Config struct { APIKey string `json:"api_key,omitempty"` + BaseURL string `json:"base_url,omitempty"` DefaultModel string `json:"default_model,omitempty"` + OpusModel string `json:"opus_model,omitempty"` + SonnetModel string `json:"sonnet_model,omitempty"` + VerifySSL *bool `json:"verify_ssl,omitempty"` ActiveProject string `json:"active_project,omitempty"` } diff --git a/apps/openant-cli/internal/python/invoke.go b/apps/openant-cli/internal/python/invoke.go index d127e11..25246df 100644 --- a/apps/openant-cli/internal/python/invoke.go +++ b/apps/openant-cli/internal/python/invoke.go @@ -27,8 +27,8 @@ type InvokeResult struct { // - stderr is streamed to the terminal in real-time (progress messages) // - stdout is captured and parsed as JSON // - Working directory is set to the openant-core lib directory if provided -// - If apiKey is non-empty, it is injected as ANTHROPIC_API_KEY in the subprocess -func Invoke(pythonPath string, args []string, workDir string, quiet bool, apiKey string) (*InvokeResult, error) { +// - extraEnv entries are injected into the subprocess environment +func Invoke(pythonPath string, args []string, workDir string, quiet bool, extraEnv map[string]string) (*InvokeResult, error) { cmdArgs := append([]string{"-m", "openant"}, args...) cmd := exec.Command(pythonPath, cmdArgs...) @@ -36,12 +36,13 @@ func Invoke(pythonPath string, args []string, workDir string, quiet bool, apiKey cmd.Dir = workDir } - // Pass through environment (Python needs ANTHROPIC_API_KEY, etc.) - // If an API key is provided via flag or config, inject it into the - // subprocess environment so Python picks it up regardless of .env files. + // Pass through environment, then overlay caller-supplied vars + // (API key, base URL, model names, etc.) cmd.Env = os.Environ() - if apiKey != "" { - cmd.Env = setEnv(cmd.Env, "ANTHROPIC_API_KEY", apiKey) + for k, v := range extraEnv { + if v != "" { + cmd.Env = setEnv(cmd.Env, k, v) + } } // Capture stdout (JSON output) diff --git a/libs/openant-core/context/application_context.py b/libs/openant-core/context/application_context.py index f7fa55d..5ea2a68 100644 --- a/libs/openant-core/context/application_context.py +++ b/libs/openant-core/context/application_context.py @@ -32,6 +32,8 @@ from anthropic import Anthropic from dotenv import load_dotenv +from utilities.config import resolve_model, create_anthropic_client, extract_text + # Load environment variables load_dotenv() @@ -462,7 +464,7 @@ def _build_type_descriptions() -> str: def generate_application_context( repo_path: Path, - model: str = "claude-sonnet-4-20250514", + model: str = "sonnet", force_regenerate: bool = False, ) -> ApplicationContext: """Generate application context using LLM analysis. @@ -502,10 +504,11 @@ def generate_application_context( sources_text += f"\n### {name}\n```\n{content}\n```\n" # Call LLM - print(f"Generating context with {model}...", file=sys.stderr) - client = Anthropic() + resolved = resolve_model(model) + print(f"Generating context with {resolved}...", file=sys.stderr) + client = create_anthropic_client() response = client.messages.create( - model=model, + model=resolved, max_tokens=2000, messages=[{ "role": "user", @@ -514,7 +517,7 @@ def generate_application_context( ) # Parse response - response_text = response.content[0].text + response_text = extract_text(response) # Extract JSON from response json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL) diff --git a/libs/openant-core/context/generate_context.py b/libs/openant-core/context/generate_context.py index 78e21d3..3c71e3b 100644 --- a/libs/openant-core/context/generate_context.py +++ b/libs/openant-core/context/generate_context.py @@ -78,8 +78,8 @@ def main(): parser.add_argument( "--model", "-m", - default="claude-sonnet-4-20250514", - help="Anthropic model to use (default: claude-sonnet-4-20250514)", + default="sonnet", + help="Model alias ('opus', 'sonnet') or full model name", ) parser.add_argument( diff --git a/libs/openant-core/core/analyzer.py b/libs/openant-core/core/analyzer.py index 7fb5966..004d4a5 100644 --- a/libs/openant-core/core/analyzer.py +++ b/libs/openant-core/core/analyzer.py @@ -313,7 +313,7 @@ def run_analysis( checkpoint.dir = checkpoint_path # Select model - model_id = "claude-opus-4-6" if model == "opus" else "claude-sonnet-4-20250514" + from utilities.config import resolve_model; model_id = resolve_model(model) print(f"[Analyze] Model: {model_id}", file=sys.stderr) # Initialize client diff --git a/libs/openant-core/core/enhancer.py b/libs/openant-core/core/enhancer.py index fef1453..f4a5730 100644 --- a/libs/openant-core/core/enhancer.py +++ b/libs/openant-core/core/enhancer.py @@ -50,7 +50,7 @@ def enhance_dataset( # Configure global rate limiter configure_rate_limiter(backoff_seconds=float(backoff_seconds)) - model_id = "claude-sonnet-4-20250514" if model == "sonnet" else "claude-opus-4-6" + from utilities.config import resolve_model; model_id = resolve_model(model) print(f"[Enhance] Mode: {mode}", file=sys.stderr) print(f"[Enhance] Model: {model_id}", file=sys.stderr) diff --git a/libs/openant-core/core/reporter.py b/libs/openant-core/core/reporter.py index 7153dab..e25d225 100644 --- a/libs/openant-core/core/reporter.py +++ b/libs/openant-core/core/reporter.py @@ -587,11 +587,12 @@ def _record_usage_in_tracker(usage: dict): """Record usage in the global TokenTracker so step_context captures it.""" try: from utilities.llm_client import get_global_tracker + from utilities.config import resolve_model tracker = get_global_tracker() # Record as a single aggregated call if usage.get("total_tokens", 0) > 0: tracker.record_call( - model="claude-opus-4-6", + model=resolve_model("opus"), input_tokens=usage["input_tokens"], output_tokens=usage["output_tokens"], ) diff --git a/libs/openant-core/experiment.py b/libs/openant-core/experiment.py index 359d41f..75fa69e 100644 --- a/libs/openant-core/experiment.py +++ b/libs/openant-core/experiment.py @@ -474,7 +474,7 @@ def run_experiment( Experiment results with metrics """ # Select model - model_id = "claude-opus-4-20250514" if model == "opus" else "claude-sonnet-4-20250514" + from utilities.config import resolve_model; model_id = resolve_model(model) print(f"Using model: {model_id}") print(f"Enhanced context: {enhanced}") print(f"Context correction: {correct_context}") diff --git a/libs/openant-core/generate_report.py b/libs/openant-core/generate_report.py index 633cd9b..f0a3b03 100644 --- a/libs/openant-core/generate_report.py +++ b/libs/openant-core/generate_report.py @@ -32,11 +32,13 @@ import anthropic from dotenv import load_dotenv +from utilities.config import resolve_model, create_anthropic_client, extract_text + # Load environment variables from .env file load_dotenv() -REPORT_MODEL = "claude-sonnet-4-20250514" +REPORT_MODEL = resolve_model("sonnet") MAX_TOKENS = 4096 @@ -198,18 +200,14 @@ def generate_remediation_guidance(findings: list) -> str: {findings_text} """ - api_key = os.getenv("ANTHROPIC_API_KEY") - if not api_key: - raise ValueError("ANTHROPIC_API_KEY not found in environment") - - client = anthropic.Anthropic(api_key=api_key) + client = create_anthropic_client() response = client.messages.create( model=REPORT_MODEL, max_tokens=MAX_TOKENS, messages=[{"role": "user", "content": prompt}] ) - return response.content[0].text + return extract_text(response) def _build_pipeline_costs_html(step_reports: list[dict]) -> str: diff --git a/libs/openant-core/openant/cli.py b/libs/openant-core/openant/cli.py index b0ce345..5cbeed6 100644 --- a/libs/openant-core/openant/cli.py +++ b/libs/openant-core/openant/cli.py @@ -810,13 +810,15 @@ def cmd_report_data(args): {findings_text} """ print("[Report] Generating remediation guidance (LLM)...", file=sys.stderr) - client = anthropic.Anthropic() + from utilities.config import create_anthropic_client, resolve_model, extract_text + _remed_model = resolve_model("sonnet") + client = create_anthropic_client() response = client.messages.create( - model="claude-sonnet-4-20250514", + model=_remed_model, max_tokens=4096, messages=[{"role": "user", "content": prompt}], ) - remediation_html = response.content[0].text + remediation_html = extract_text(response) # Post-process: linkify finding references like #4, #12-#14 import re @@ -829,7 +831,7 @@ def _linkify_finding(m): usage = response.usage tracker = get_global_tracker() tracker.record_call( - model="claude-sonnet-4-20250514", + model=_remed_model, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, ) diff --git a/libs/openant-core/report/generator.py b/libs/openant-core/report/generator.py index c996250..3a99f22 100644 --- a/libs/openant-core/report/generator.py +++ b/libs/openant-core/report/generator.py @@ -12,12 +12,13 @@ from pathlib import Path from dotenv import load_dotenv +from utilities.config import resolve_model, create_anthropic_client, extract_text from .schema import validate_pipeline_output, ValidationError load_dotenv() PROMPTS_DIR = Path(__file__).parent / "prompts" -MODEL = "claude-opus-4-6" +MODEL = resolve_model("opus") # Pricing per million tokens _PRICING = { @@ -136,7 +137,7 @@ def generate_summary_report(pipeline_data: dict) -> tuple[str, dict]: output_tokens, total_tokens, cost_usd. """ _check_api_key() - client = anthropic.Anthropic() + client = create_anthropic_client() summary_data = _compact_for_summary(pipeline_data) system_prompt = load_prompt("system") @@ -149,7 +150,7 @@ def generate_summary_report(pipeline_data: dict) -> tuple[str, dict]: messages=[{"role": "user", "content": user_prompt}] ) - return response.content[0].text, _extract_usage(response) + return extract_text(response), _extract_usage(response) def _splice_code_section(llm_output: str, code_section: str) -> str: @@ -199,7 +200,7 @@ def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[st (disclosure_text, usage_dict) """ _check_api_key() - client = anthropic.Anthropic() + client = create_anthropic_client() system_prompt = load_prompt("system") @@ -225,7 +226,7 @@ def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[st messages=[{"role": "user", "content": user_prompt}] ) - llm_output = response.content[0].text + llm_output = extract_text(response) final_output = _splice_code_section(llm_output, code_section) return final_output, _extract_usage(response) diff --git a/libs/openant-core/utilities/agentic_enhancer/agent.py b/libs/openant-core/utilities/agentic_enhancer/agent.py index 62061b7..89e30dd 100644 --- a/libs/openant-core/utilities/agentic_enhancer/agent.py +++ b/libs/openant-core/utilities/agentic_enhancer/agent.py @@ -16,6 +16,7 @@ import anthropic +from ..config import create_anthropic_client, resolve_model from ..llm_client import TokenTracker, get_global_tracker from ..rate_limiter import get_rate_limiter from .repository_index import RepositoryIndex @@ -26,7 +27,7 @@ # Use Sonnet for exploration (cost-effective) -AGENT_MODEL = "claude-sonnet-4-20250514" +AGENT_MODEL = resolve_model("sonnet") # Safety limits MAX_ITERATIONS = 20 @@ -126,7 +127,7 @@ def __init__( self.tool_executor = ToolExecutor(index) self.entry_points = entry_points or set() self.reachability = reachability - self.client = client or anthropic.Anthropic(max_retries=5) + self.client = client or create_anthropic_client(max_retries=5) def analyze_unit( self, diff --git a/libs/openant-core/utilities/config.py b/libs/openant-core/utilities/config.py new file mode 100644 index 0000000..5ab32b0 --- /dev/null +++ b/libs/openant-core/utilities/config.py @@ -0,0 +1,117 @@ +""" +OpenAnt LLM configuration. + +The Go CLI reads ~/.config/openant/config.json and injects settings as +environment variables before spawning the Python subprocess: + + ANTHROPIC_API_KEY → API key (required by SDK, can be dummy for local AI) + ANTHROPIC_BASE_URL → LLM endpoint (e.g. http://localhost:8080) + OPENANT_OPUS_MODEL → Model name for heavy tasks (Stage 1, verify, report) + OPENANT_SONNET_MODEL → Model name for lighter tasks (enhance, consistency) + +This module provides helpers that the rest of the Python codebase uses +instead of hardcoded model names and direct anthropic.Anthropic() calls. +""" + +import os +from typing import Optional + + +# Default model names (Anthropic cloud) +_DEFAULT_OPUS = "claude-opus-4-6" +_DEFAULT_SONNET = "claude-sonnet-4-20250514" + + +def get_base_url() -> Optional[str]: + """Return the LLM API base URL, or None for Anthropic's default.""" + return os.environ.get("ANTHROPIC_BASE_URL") or None + + +def get_api_key() -> Optional[str]: + """Return the API key from environment.""" + return os.environ.get("ANTHROPIC_API_KEY") or None + + +def resolve_model(alias: str) -> str: + """Resolve 'opus'/'sonnet' to an actual model ID. + + Reads OPENANT_OPUS_MODEL / OPENANT_SONNET_MODEL env vars (set by the + Go CLI from config.json). Falls back to Claude model names if not set. + + If *alias* is not 'opus' or 'sonnet', it is returned as-is (allows + passing full model IDs directly). + """ + if alias == "opus": + return os.environ.get("OPENANT_OPUS_MODEL", _DEFAULT_OPUS) + if alias == "sonnet": + return os.environ.get("OPENANT_SONNET_MODEL", _DEFAULT_SONNET) + return alias + + +def _should_verify_ssl() -> bool: + """Check whether SSL verification is enabled. + + Reads OPENANT_VERIFY_SSL env var (set by Go CLI from config.json). + Defaults to True (verify) unless explicitly set to 'false'. + """ + val = os.environ.get("OPENANT_VERIFY_SSL", "true").lower() + return val not in ("false", "0", "no") + + +def create_anthropic_client(**extra_kwargs): + """Create an ``anthropic.Anthropic`` instance using config. + + Reads ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY from the environment + (injected by the Go CLI from config.json). Use this everywhere instead + of ``anthropic.Anthropic()`` directly. + + When a custom base_url is configured (local AI server), timeouts are + extended to allow for model loading (llama-swap cold starts, etc.). + SSL verification can be disabled via ``openant config set verify-ssl false`` + for servers with self-signed certificates. + """ + import anthropic + import httpx + + api_key = get_api_key() + if not api_key: + raise ValueError( + "No API key found. Run: openant config set api-key" + ) + + kwargs: dict = {"api_key": api_key} + base_url = get_base_url() + if base_url: + kwargs["base_url"] = base_url + # Local AI servers may need time to load models (cold start). + # Default SDK connect timeout is 5s which is too short for + # llama-swap model swapping. + verify = _should_verify_ssl() + kwargs["http_client"] = httpx.Client( + verify=verify, + timeout=httpx.Timeout( + connect=300.0, # 5 min for model loading / cold start + read=600.0, # 10 min for inference + write=60.0, # 1 min for sending request + pool=120.0, # 2 min for connection pool + ), + ) + kwargs.update(extra_kwargs) + return anthropic.Anthropic(**kwargs) + + +def extract_text(response) -> str: + """Extract the text content from an LLM response. + + Handles both standard models (content[0] is TextBlock) and thinking/ + reasoning models (content may start with ThinkingBlock(s) before the + TextBlock). Returns the first TextBlock's text, or empty string if + no text block is found. + """ + if not response.content: + return "" + for block in response.content: + if getattr(block, "type", None) == "text": + return block.text + # Fallback: try the first block anyway + return getattr(response.content[0], "text", "") diff --git a/libs/openant-core/utilities/context_corrector.py b/libs/openant-core/utilities/context_corrector.py index 918dda6..903fe2b 100644 --- a/libs/openant-core/utilities/context_corrector.py +++ b/libs/openant-core/utilities/context_corrector.py @@ -16,6 +16,7 @@ import sys from typing import Optional +from .config import resolve_model from .llm_client import AnthropicClient, TokenTracker, get_global_tracker @@ -102,7 +103,7 @@ def parse_missing_context_with_llm( prompt = get_missing_context_prompt(reasoning) try: - llm_response = client.analyze_sync(prompt, model="claude-sonnet-4-20250514") + llm_response = client.analyze_sync(prompt, model=resolve_model("sonnet")) parsed = _parse_json_response(llm_response) if parsed and "missing_context" in parsed: @@ -254,7 +255,7 @@ def search_files_for_context( prompt = get_file_search_prompt(missing_context, files_content, batch_info) try: - response = client.analyze_sync(prompt, model="claude-sonnet-4-20250514") + response = client.analyze_sync(prompt, model=resolve_model("sonnet")) result = _parse_json_response(response) if result and result.get("found_files"): diff --git a/libs/openant-core/utilities/context_enhancer.py b/libs/openant-core/utilities/context_enhancer.py index 2ffbfe6..356da88 100644 --- a/libs/openant-core/utilities/context_enhancer.py +++ b/libs/openant-core/utilities/context_enhancer.py @@ -25,6 +25,7 @@ import anthropic +from .config import create_anthropic_client, resolve_model from .llm_client import AnthropicClient, TokenTracker, get_global_tracker, reset_global_tracker from .agentic_enhancer import RepositoryIndex, enhance_unit_with_agent, load_index_from_file from .rate_limiter import get_rate_limiter, is_rate_limit_error, is_retryable_error @@ -45,7 +46,7 @@ def _get_step_checkpoint(): # Use Sonnet for context enhancement (cost-effective auxiliary task) -CONTEXT_ENHANCEMENT_MODEL = "claude-sonnet-4-20250514" +CONTEXT_ENHANCEMENT_MODEL = resolve_model("sonnet") def _build_error_info(exc: Exception) -> dict: @@ -568,7 +569,7 @@ def enhance_dataset_agentic( remaining = total - len(processed_ids) self._log("info", f"Enhancing {remaining} units with agentic analysis ({len(processed_ids)} already done)", units=remaining) self._log("info", "Mode: Iterative tool use (traces call paths)") - self._log("info", "Model: claude-sonnet-4-20250514") + self._log("info", f"Model: {CONTEXT_ENHANCEMENT_MODEL}") mode = "sequential" if workers <= 1 else f"parallel ({workers} workers)" self._log("info", f"Workers: {mode}") if checkpoint_dir: @@ -585,7 +586,7 @@ def enhance_dataset_agentic( # which spawns a new httpx connection pool. With 1000+ units and 8 workers, # this exhausted file descriptors (macOS limit ~256). The httpx.Client # underlying anthropic.Anthropic is thread-safe, so sharing is correct. - shared_client = anthropic.Anthropic(max_retries=5) + shared_client = create_anthropic_client(max_retries=5) # Filter to unprocessed units units_to_process = [(i, unit) for i, unit in enumerate(units) if unit.get("id") not in processed_ids] diff --git a/libs/openant-core/utilities/context_reviewer.py b/libs/openant-core/utilities/context_reviewer.py index b17107d..9d77472 100644 --- a/libs/openant-core/utilities/context_reviewer.py +++ b/libs/openant-core/utilities/context_reviewer.py @@ -12,6 +12,7 @@ import sys from typing import Optional +from .config import resolve_model from .llm_client import AnthropicClient from .context_corrector import gather_source_files, search_files_for_context @@ -176,7 +177,7 @@ def review_context( prompt = get_context_review_prompt(code, route, handler, files_included) try: - response = self.client.analyze_sync(prompt, model="claude-sonnet-4-20250514") + response = self.client.analyze_sync(prompt, model=resolve_model("sonnet")) review = self._parse_json_response(response) if not review: diff --git a/libs/openant-core/utilities/dynamic_tester/test_generator.py b/libs/openant-core/utilities/dynamic_tester/test_generator.py index c95b88a..d272ac6 100644 --- a/libs/openant-core/utilities/dynamic_tester/test_generator.py +++ b/libs/openant-core/utilities/dynamic_tester/test_generator.py @@ -15,8 +15,9 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from utilities.llm_client import AnthropicClient, TokenTracker +from utilities.config import resolve_model -SONNET_MODEL = "claude-sonnet-4-20250514" +SONNET_MODEL = resolve_model("sonnet") # Map language strings to Dockerfile template names LANGUAGE_MAP = { diff --git a/libs/openant-core/utilities/finding_verifier.py b/libs/openant-core/utilities/finding_verifier.py index 2e66b7c..9870260 100644 --- a/libs/openant-core/utilities/finding_verifier.py +++ b/libs/openant-core/utilities/finding_verifier.py @@ -40,6 +40,8 @@ import anthropic +from .config import create_anthropic_client, resolve_model, extract_text + from .llm_client import TokenTracker, get_global_tracker from .rate_limiter import get_rate_limiter @@ -62,7 +64,7 @@ ApplicationContext = None -VERIFIER_MODEL = "claude-opus-4-6" +VERIFIER_MODEL = resolve_model("opus") MAX_ITERATIONS = 20 MAX_TOKENS_PER_RESPONSE = 4096 @@ -271,7 +273,7 @@ def __init__( self.verbose = verbose self.app_context = app_context self.tool_executor = ToolExecutor(index) - self.client = client or anthropic.Anthropic(max_retries=5) + self.client = client or create_anthropic_client(max_retries=5) self.logger = logger or _null_logger self._use_logger = logger is not None @@ -848,7 +850,7 @@ def _resolve_inconsistency( ) # Parse response - text = response.content[0].text if response.content else "" + text = extract_text(response) result = self._parse_json_from_text(text) if result: diff --git a/libs/openant-core/utilities/ground_truth_challenger.py b/libs/openant-core/utilities/ground_truth_challenger.py index b0ad1db..f977511 100644 --- a/libs/openant-core/utilities/ground_truth_challenger.py +++ b/libs/openant-core/utilities/ground_truth_challenger.py @@ -18,6 +18,7 @@ from typing import Optional from dataclasses import dataclass +from .config import resolve_model from .llm_client import AnthropicClient @@ -209,7 +210,7 @@ class GroundTruthChallenger: 2. Validate false negatives - did the model miss something, or is the ground truth wrong? """ - def __init__(self, client: AnthropicClient, model: str = "claude-sonnet-4-20250514"): + def __init__(self, client: AnthropicClient, model: str = resolve_model("sonnet")): """ Initialize the challenger. diff --git a/libs/openant-core/utilities/json_corrector.py b/libs/openant-core/utilities/json_corrector.py index dd35cda..dea5ee0 100644 --- a/libs/openant-core/utilities/json_corrector.py +++ b/libs/openant-core/utilities/json_corrector.py @@ -83,7 +83,7 @@ def extract_json_with_llm( # Use Sonnet for extraction (faster/cheaper) llm_response = client.analyze_sync( prompt, - model="claude-sonnet-4-20250514", + model=resolve_model("sonnet"), max_tokens=2048 ) return _parse_json_response(llm_response) diff --git a/libs/openant-core/utilities/llm_client.py b/libs/openant-core/utilities/llm_client.py index ea356bf..30349a9 100644 --- a/libs/openant-core/utilities/llm_client.py +++ b/libs/openant-core/utilities/llm_client.py @@ -10,7 +10,7 @@ Usage: from utilities.llm_client import AnthropicClient, get_global_tracker - client = AnthropicClient(model="claude-opus-4-20250514") + client = AnthropicClient(model="opus") # or "sonnet", or a full model ID response = client.analyze_sync("Analyze this code...") tracker = get_global_tracker() @@ -183,29 +183,28 @@ def reset_global_tracker(): class AnthropicClient: """ - Client for Anthropic Claude API. + LLM API client with token tracking. - Uses Claude Opus 4 for vulnerability analysis. - Tracks token usage and costs for all calls. + Wraps anthropic.Anthropic and routes through config (base URL, model names) + so it works with both Anthropic cloud and local AI servers. """ - def __init__(self, model: str = "claude-opus-4-20250514", tracker: TokenTracker = None): + def __init__(self, model: str = "opus", tracker: TokenTracker = None): """ - Initialize the Anthropic client. + Initialize the client. Args: - model: Model identifier. Default is Claude Opus 4 (highest capability). - Use "claude-sonnet-4-20250514" for cost-effective option. + model: Model alias ("opus", "sonnet") or full model ID. + Resolved via OPENANT_OPUS_MODEL / OPENANT_SONNET_MODEL + env vars, falling back to Claude model names. tracker: Optional TokenTracker instance. Uses global tracker if not provided. """ - load_dotenv() + from .config import create_anthropic_client, resolve_model - api_key = os.getenv("ANTHROPIC_API_KEY") - if not api_key: - raise ValueError("ANTHROPIC_API_KEY not found in environment") + load_dotenv() - self.client = anthropic.Anthropic(api_key=api_key, max_retries=5) - self.model = model + self.client = create_anthropic_client(max_retries=5) + self.model = resolve_model(model) self.tracker = tracker or _global_tracker self.last_call = None # Store last call details @@ -245,7 +244,8 @@ async def analyze(self, prompt: str, max_tokens: int = 8192) -> str: output_tokens=message.usage.output_tokens ) - return message.content[0].text + from .config import extract_text + return extract_text(message) def analyze_sync(self, prompt: str, max_tokens: int = 8192, model: str = None, system: str = None) -> str: """ @@ -291,7 +291,8 @@ def analyze_sync(self, prompt: str, max_tokens: int = 8192, model: str = None, s output_tokens=message.usage.output_tokens ) - return message.content[0].text + from .config import extract_text + return extract_text(message) def get_last_call(self) -> Optional[dict]: """ diff --git a/libs/openant-core/utilities/stage1_consistency.py b/libs/openant-core/utilities/stage1_consistency.py index 96b54b3..89363d7 100644 --- a/libs/openant-core/utilities/stage1_consistency.py +++ b/libs/openant-core/utilities/stage1_consistency.py @@ -18,7 +18,8 @@ # Use a cheaper/faster model for consistency checks -CONSISTENCY_MODEL = "claude-sonnet-4-20250514" +from utilities.config import resolve_model, extract_text +CONSISTENCY_MODEL = resolve_model("sonnet") MAX_TOKENS = 4096 @@ -281,7 +282,7 @@ def _resolve_stage1_inconsistency( ) # Parse response - text = response.content[0].text if response.content else "" + text = extract_text(response) # Extract JSON from response json_match = re.search(r'\{[\s\S]*\}', text)