From f85339bcb136b511cc3c86ddec00a95883d78439 Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 5 Jun 2026 22:28:57 +0100 Subject: [PATCH] fix(extract): don't require LLM API key for code-only corpus (#1122) Backend resolution now defers until after file detection. A code-only corpus (pure AST, zero LLM calls) runs without any API key. Key validation only fires when needs_llm=True (semantic_files non-empty or --dedup-llm passed). Error message now names why a key is needed and notes that code-only corpora need none. Applied from PR #1123 with one fix: _clear_backend_keys in tests now also clears AWS_PROFILE/REGION, OLLAMA_BASE_URL to prevent CI flakes on machines with ambient credentials. Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 172 +++++++++++++++++++------------------- tests/test_extract_cli.py | 77 +++++++++++++++++ 2 files changed, 164 insertions(+), 85 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 5d91cec9..a695714a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -3808,91 +3808,6 @@ def main() -> None: if cli_max_workers is not None: os.environ["GRAPHIFY_MAX_WORKERS"] = str(cli_max_workers) - # Backend resolution. If user did not pass --backend, sniff env. - # If backend was explicitly requested, validate its key is present - # and surface a clear error early — don't let extract_corpus_parallel - # raise mid-run after we've spent time on AST extraction. - from graphify.llm import ( - BACKENDS as _BACKENDS, - detect_backend as _detect_backend, - estimate_cost as _estimate_cost, - extract_corpus_parallel as _extract_corpus_parallel, - _format_backend_env_keys, - _get_backend_api_key, - ) - if backend is None: - backend = _detect_backend() - if backend is None: - print( - "error: no LLM API key found. Set GEMINI_API_KEY or GOOGLE_API_KEY " - "(gemini), MOONSHOT_API_KEY (kimi), ANTHROPIC_API_KEY (claude), " - "OPENAI_API_KEY (openai), DEEPSEEK_API_KEY (deepseek), " - "or pass --backend.", - file=sys.stderr, - ) - sys.exit(1) - if backend not in _BACKENDS: - print( - f"error: unknown backend '{backend}'. " - f"Available: {', '.join(sorted(_BACKENDS))}", - file=sys.stderr, - ) - sys.exit(1) - if backend == "ollama": - # Fail closed with a clean message (not a deep traceback) if - # OLLAMA_BASE_URL points at a link-local/metadata address. warn=False: - # the later in-flow call owns the user-facing warning for LAN hosts. - from graphify.llm import _validate_ollama_base_url - _oll_url = os.environ.get("OLLAMA_BASE_URL", _BACKENDS["ollama"].get("base_url", "")) - try: - _validate_ollama_base_url(_oll_url, warn=False) - except ValueError as exc: - print(f"error: {exc}", file=sys.stderr) - sys.exit(2) - if not _get_backend_api_key(backend): - # Ollama on a loopback URL ignores auth entirely; don't block - # the run just because OLLAMA_API_KEY is unset (issue #792). - # extract_files_direct already prints a warning and substitutes - # a placeholder key in that case. - allow_no_key = False - if backend == "ollama": - from urllib.parse import urlparse - ollama_url = os.environ.get( - "OLLAMA_BASE_URL", - _BACKENDS["ollama"].get("base_url", ""), - ) - try: - host = (urlparse(ollama_url).hostname or "").lower() - except Exception: - host = "" - allow_no_key = ( - host in ("localhost", "127.0.0.1", "::1") - or host.startswith("127.") - ) - elif backend == "bedrock": - allow_no_key = bool( - os.environ.get("AWS_PROFILE") - or os.environ.get("AWS_REGION") - or os.environ.get("AWS_DEFAULT_REGION") - or os.environ.get("AWS_ACCESS_KEY_ID") - ) - elif backend == "claude-cli": - import shutil as _shutil - allow_no_key = _shutil.which("claude") is not None - if not allow_no_key: - print( - "error: backend 'claude-cli' requires the `claude` CLI on $PATH " - "(install Claude Code and run `claude` once to authenticate).", - file=sys.stderr, - ) - sys.exit(1) - if not allow_no_key: - print( - f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", - file=sys.stderr, - ) - sys.exit(1) - # Resolve output dir. The user-facing contract is "/graphify-out/" # so a fresh checkout writes graphify-out/ at the project root, matching # the skill.md pipeline. @@ -3952,6 +3867,93 @@ def main() -> None: f"{len(image_files)} images" ) + # Resolve the LLM backend only now that we know whether the corpus + # needs one. A code-only corpus is pure local AST and must not require + # an API key; the key is enforced below only when there's LLM work. + from graphify.llm import ( + BACKENDS as _BACKENDS, + detect_backend as _detect_backend, + estimate_cost as _estimate_cost, + extract_corpus_parallel as _extract_corpus_parallel, + _format_backend_env_keys, + _get_backend_api_key, + ) + needs_llm = bool(semantic_files) or dedup_llm + if backend is None and needs_llm: + backend = _detect_backend() + if backend is not None and backend not in _BACKENDS: + print( + f"error: unknown backend '{backend}'. " + f"Available: {', '.join(sorted(_BACKENDS))}", + file=sys.stderr, + ) + sys.exit(1) + if needs_llm: + if backend is None: + reasons = [] + if semantic_files: + reasons.append( + f"{len(semantic_files)} doc/paper/image file(s) need semantic extraction" + ) + if dedup_llm: + reasons.append("--dedup-llm was passed") + print( + "error: no LLM API key found (" + "; ".join(reasons) + "). " + "Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY " + "(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), " + "DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only " + "corpus needs no key.", + file=sys.stderr, + ) + sys.exit(1) + if backend == "ollama": + from graphify.llm import _validate_ollama_base_url + _oll_url = os.environ.get("OLLAMA_BASE_URL", _BACKENDS["ollama"].get("base_url", "")) + try: + _validate_ollama_base_url(_oll_url, warn=False) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(2) + if not _get_backend_api_key(backend): + allow_no_key = False + if backend == "ollama": + from urllib.parse import urlparse + ollama_url = os.environ.get( + "OLLAMA_BASE_URL", + _BACKENDS["ollama"].get("base_url", ""), + ) + try: + host = (urlparse(ollama_url).hostname or "").lower() + except Exception: + host = "" + allow_no_key = ( + host in ("localhost", "127.0.0.1", "::1") + or host.startswith("127.") + ) + elif backend == "bedrock": + allow_no_key = bool( + os.environ.get("AWS_PROFILE") + or os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or os.environ.get("AWS_ACCESS_KEY_ID") + ) + elif backend == "claude-cli": + import shutil as _shutil + allow_no_key = _shutil.which("claude") is not None + if not allow_no_key: + print( + "error: backend 'claude-cli' requires the `claude` CLI on $PATH " + "(install Claude Code and run `claude` once to authenticate).", + file=sys.stderr, + ) + sys.exit(1) + if not allow_no_key: + print( + f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", + file=sys.stderr, + ) + sys.exit(1) + # AST extraction on code files. Empty code list (docs-only corpus) is # the issue #698 case — skip cleanly instead of crashing inside extract(). ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} diff --git a/tests/test_extract_cli.py b/tests/test_extract_cli.py index 6998bcce..13d6fe91 100644 --- a/tests/test_extract_cli.py +++ b/tests/test_extract_cli.py @@ -121,3 +121,80 @@ def test_extract_succeeds_when_at_least_one_chunk_completes( assert (out_dir / "graphify-out" / "graph.json").exists(), ( "graph.json must be written on the happy path" ) + + +def _code_only_corpus(tmp_path): + """A corpus with only code — no docs/papers/images.""" + (tmp_path / "auth.py").write_text( + "def login(user):\n return validate(user)\n\n" + "def validate(user):\n return True\n" + ) + return tmp_path + + +def _clear_backend_keys(monkeypatch): + """Clear every env var that detect_backend() or _get_backend_api_key() reads.""" + for key in ( + "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY", "MOONSHOT_API_KEY", + # bedrock: presence of any of these is treated as a valid credential + "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", + # ollama: a set OLLAMA_BASE_URL triggers backend detection + "OLLAMA_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + + +def test_extract_codeonly_succeeds_without_api_key(monkeypatch, tmp_path): + """A code-only corpus must run with no LLM API key. + + Regression: graphify extract validated a backend upfront and exited 1 with + 'no LLM API key found' even for a code-only corpus that never calls a model. + The keyless AST path now runs to a written graph.json (#1122). + """ + corpus = _code_only_corpus(tmp_path) + out_dir = tmp_path / "out" + _clear_backend_keys(monkeypatch) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, "argv", + ["graphify", "extract", str(corpus), "--out", str(out_dir)], + ) + + try: + mainmod.main() + except SystemExit as exc: + assert exc.code in (None, 0), f"unexpected exit code {exc.code}" + + graph = out_dir / "graphify-out" / "graph.json" + assert graph.exists(), "code-only extract must write graph.json without a key" + import json + assert len(json.loads(graph.read_text()).get("nodes", [])) > 0 + + +def test_extract_without_key_still_errors_when_docs_present( + monkeypatch, tmp_path, capsys +): + """Key requirement still fires when semantic work is needed. + + A corpus with a Markdown doc needs LLM semantic extraction, so a keyless + extract must exit 1 with clear guidance (#1122). + """ + corpus = _make_corpus(tmp_path) # includes a Markdown doc + out_dir = tmp_path / "out" + _clear_backend_keys(monkeypatch) + # Patch detect_backend too so ambient AWS/ollama env can't slip through. + monkeypatch.setattr("graphify.llm.detect_backend", lambda: None) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, "argv", + ["graphify", "extract", str(corpus), "--out", str(out_dir)], + ) + + with pytest.raises(SystemExit) as exc_info: + mainmod.main() + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "no LLM API key found" in err + assert "code-only corpus needs no key" in err + assert not (out_dir / "graphify-out" / "graph.json").exists()