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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3a4bdf54cc
commit
f85339bcb1
+87
-85
@@ -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 "<out>/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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user