diff --git a/README.md b/README.md index eef5de1f3..030aa25e8 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ Backends, API keys, hook behavior, and tuning are controlled by environment vari - **Code files**: processed locally via tree-sitter. Nothing leaves your machine. A code-only corpus requires no API key: `graphify extract` runs fully offline. On a mixed repo, add `--code-only` to index just the code and skip the docs/PDFs/images that would otherwise need an LLM. - **Video / audio**: transcribed locally with faster-whisper. Nothing leaves your machine. - **Docs, PDFs, images**: sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), or the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription). The `--dedup-llm` flag uses the same key. -- **Data residency**: `graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China. +- **Data residency**: `graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). When it auto-detects, it prints the exact environment variable that chose the provider, so a run is never a surprise. To choose explicitly, pass `--backend`, or set `GRAPHIFY_BACKEND=`; set `GRAPHIFY_BACKEND=none` to disable all LLM backends (semantic extraction and the `claude -p` labeling fallback) and keep a code-only graph. For code with data-residency requirements, use `GRAPHIFY_BACKEND=ollama` / `--backend ollama` (fully local). Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China. - **No telemetry**, no usage tracking, no analytics. - **Query logging**: every `graphify query`, `graphify path`, `graphify explain`, and MCP `query_graph` call is logged to `~/.cache/graphify-queries.log` in JSON Lines format (timestamp, question, corpus, nodes returned, duration). Full subgraph responses are **not** stored by default. Set `GRAPHIFY_QUERY_LOG_DISABLE=1` to opt out, or `GRAPHIFY_QUERY_LOG=/dev/null` to silence without disabling the code path. diff --git a/graphify/cli.py b/graphify/cli.py index 2f88318f2..c605443c1 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -4096,15 +4096,40 @@ def dispatch_command(cmd: str) -> None: # 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, + detect_backend_with_source as _detect_backend_with_source, + backend_disabled_by_env as _backend_disabled_by_env, 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() + backend_source = None + if backend is None and needs_llm and _backend_disabled_by_env(): + # GRAPHIFY_BACKEND=none: keep the local AST pass and skip every LLM + # step, rather than failing or quietly auto-selecting a cloud provider + # (#4361). Mirrors --code-only's report of what was held back. + skipped = len(semantic_files) + print( + f"[graphify extract] GRAPHIFY_BACKEND=none: skipping LLM work " + f"({skipped} non-code file(s) and LLM dedup kept out); indexing code only." + ) + semantic_files = [] + doc_files = [] + paper_files = [] + image_files = [] + dedup_llm = False + needs_llm = False + elif backend is None and needs_llm: + backend, backend_source = _detect_backend_with_source() + if backend and backend_source.startswith("auto-detected"): + # Name the exact setting that chose the backend, so sending the + # corpus to a cloud provider is never a surprise (#4361). + print( + f"[graphify] using backend '{backend}' ({backend_source}); pass " + "--backend or set GRAPHIFY_BACKEND to choose explicitly, or " + "GRAPHIFY_BACKEND=none to disable LLM work." + ) if backend is not None and backend not in _BACKENDS: print( f"error: unknown backend '{backend}'. " @@ -4418,7 +4443,8 @@ def dispatch_command(cmd: str) -> None: print(f"[graphify extract] semantic cache: {sem_cache_hits} hit / {sem_cache_misses} miss") if uncached_paths: - print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {backend}...") + _via = f"{backend}" + (f", {backend_source}" if backend_source else "") + print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {_via}...") corpus_kwargs: dict = { "backend": backend, "model": model, diff --git a/graphify/llm.py b/graphify/llm.py index 47413f452..da87d6fb8 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1993,6 +1993,11 @@ def extract_files_direct( """ files = [f if isinstance(f, (Path, FileSlice)) else Path(f) for f in files] if backend is None: + if backend_disabled_by_env(): + raise ValueError( + "LLM backend disabled by GRAPHIFY_BACKEND=none. Unset it (or pass " + "backend= explicitly) to run semantic extraction." + ) backend = detect_backend() if backend is None: raise ValueError( @@ -3231,6 +3236,8 @@ _BACKEND_DETECTION_EXTRA_ENV = ( "AZURE_OPENAI_ENDPOINT", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "OLLAMA_BASE_URL", "OLLAMA_HOST", + # The explicit selector/disable switch, read by _graphify_backend_setting(). + "GRAPHIFY_BACKEND", ) @@ -3249,10 +3256,51 @@ def backend_detection_env_vars() -> tuple[str, ...]: return tuple(seen) -def detect_backend() -> str | None: - """Return the name of whichever backend has an API key set, or None. +# Values of GRAPHIFY_BACKEND that mean "do not use any LLM backend". +_BACKEND_DISABLE_SENTINELS = frozenset({"none", "off", "disable", "disabled"}) - Priority: gemini → kimi → claude → openai → deepseek → azure → bedrock → ollama (last, opt-in). + +def _graphify_backend_setting() -> str | None: + """The raw ``GRAPHIFY_BACKEND`` value, or None when unset/blank. + + Unlike the ambient API-key variables, this is a deliberate choice the user + makes, so it wins over auto-detection. ``none`` (and its synonyms) turns + every LLM backend off. Documented in the README's data-residency note (#4361). + """ + value = os.environ.get("GRAPHIFY_BACKEND", "").strip() + return value or None + + +def backend_disabled_by_env() -> bool: + """True when ``GRAPHIFY_BACKEND`` explicitly disables every LLM backend. + + Lets a caller tell "the user told us not to call any provider" apart from + "no provider was found", so it can skip the LLM step with the right message + instead of reporting a missing key (#4361). + """ + value = _graphify_backend_setting() + return value is not None and value.lower() in _BACKEND_DISABLE_SENTINELS + + +def _first_set_env(keys) -> str | None: + """First non-empty variable in *keys*, for naming what selected a backend.""" + for key in keys: + if os.environ.get(key): + return key + return None + + +def detect_backend_with_source() -> "tuple[str | None, str]": + """Return ``(backend, source)``: the backend to use and what selected it. + + ``source`` is a short human string for a log line that names the exact + setting which chose the backend, so an auto-detected cloud run is never a + surprise (#4361). + + An explicit ``GRAPHIFY_BACKEND`` wins — a backend name selects it, a disable + sentinel returns ``None``. Otherwise the ambient environment is probed, in + priority order: gemini → kimi → claude → openai → deepseek → azure → bedrock + → ollama (last, opt-in), then any custom registered backend. Ollama is intentionally checked LAST so a paid API key (Anthropic/OpenAI/etc.) is never silently shadowed by an incidental OLLAMA_BASE_URL in the environment @@ -3260,13 +3308,19 @@ def detect_backend() -> str | None: key now keeps you on the paid backend; remove the paid key (or pass --backend ollama explicitly) to route to the local model. """ + setting = _graphify_backend_setting() + if setting is not None: + if setting.lower() in _BACKEND_DISABLE_SENTINELS: + return None, f"GRAPHIFY_BACKEND={setting}" + return setting, f"GRAPHIFY_BACKEND={setting}" for backend in ("gemini", "kimi", "claude", "openai", "deepseek"): if _get_backend_api_key(backend): - return backend + env = _first_set_env(_backend_env_keys(backend)) or backend + return backend, f"auto-detected from {env}" if _get_backend_api_key("azure") and os.environ.get("AZURE_OPENAI_ENDPOINT"): - return "azure" + return "azure", "auto-detected from AZURE_OPENAI_ENDPOINT" if os.environ.get("AWS_PROFILE") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"): - return "bedrock" + return "bedrock", "auto-detected from AWS credentials" # Honor Ollama's own OLLAMA_HOST here too, not just OLLAMA_BASE_URL (#1940) — # otherwise a user who set the standard Ollama var but no --backend still # gets "no LLM API key found". Empty default -> falsy when neither is set, @@ -3274,12 +3328,23 @@ def detect_backend() -> str | None: ollama_url = _resolve_ollama_base_url("") if ollama_url: _validate_ollama_base_url(ollama_url) - return "ollama" + return "ollama", "auto-detected from Ollama configuration" for name in BACKENDS: if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"): if _get_backend_api_key(name): - return name - return None + env = _first_set_env(_backend_env_keys(name)) or name + return name, f"auto-detected from {env}" + return None, "no backend configured" + + +def detect_backend() -> str | None: + """Return the name of whichever backend to use, or None. + + Thin wrapper over :func:`detect_backend_with_source` that keeps the + historical ``str | None`` return type; see it for the priority order and the + explicit ``GRAPHIFY_BACKEND`` override/disable (#4361). + """ + return detect_backend_with_source()[0] def _claude_cli_available() -> bool: @@ -3604,7 +3669,19 @@ def generate_community_labels( """CLI entry point: resolve a backend, name communities, and degrade to ``Community N`` placeholders on any failure (no backend, API error, malformed reply). Returns ``(labels, source)`` where source is ``"llm"`` or - ``"placeholder"``. Never raises.""" + ``"placeholder"``. Never raises. + + ``GRAPHIFY_BACKEND=none`` disables the whole step, including the ``claude + -p`` fallback below, so a user can keep LLM work off no matter what is on + PATH (#4361).""" + if backend is None and backend_disabled_by_env(): + if not quiet: + print( + "[graphify label] LLM backends disabled by GRAPHIFY_BACKEND=none; " + "keeping Community N placeholders.", + file=sys.stderr, + ) + return _placeholder_community_labels(communities), "placeholder" if backend is None: try: backend = detect_backend() @@ -3617,7 +3694,15 @@ def generate_community_labels( # run without a configured backend. Here the alternative is not an error but # a SILENT DOWNGRADE: replacing every real community name with # "Community N" and exiting 0, which overwrites a good graph with a worse - # one while reporting success. An installed CLI is better than that. + # one while reporting success. An installed CLI is better than that. The + # fallback is announced and GRAPHIFY_BACKEND=none opts out of it (#4361). + if not quiet: + print( + "[graphify label] no API key found; naming communities via the " + "`claude` CLI (claude -p). Set an API key or pass --backend to " + "choose explicitly, or GRAPHIFY_BACKEND=none to disable.", + file=sys.stderr, + ) backend = "claude-cli" if not backend: if not quiet: diff --git a/tests/test_backend_env_isolation.py b/tests/test_backend_env_isolation.py index b8a66e1cf..607d35db1 100644 --- a/tests/test_backend_env_isolation.py +++ b/tests/test_backend_env_isolation.py @@ -17,7 +17,10 @@ ROOT = Path(__file__).resolve().parent.parent def test_env_list_covers_every_variable_detect_backend_reads(): - source = inspect.getsource(llm.detect_backend) + inspect.getsource(llm._resolve_ollama_base_url) + source = ( + inspect.getsource(llm.detect_backend_with_source) + + inspect.getsource(llm._resolve_ollama_base_url) + ) direct = set(re.findall(r'os\.environ\.get\("([A-Z_]+)"', source)) builtin_keys = {key for name in llm.BACKENDS for key in llm._backend_env_keys(name)} listed = set(llm.backend_detection_env_vars()) diff --git a/tests/test_backend_opt_in.py b/tests/test_backend_opt_in.py new file mode 100644 index 000000000..e31b35c66 --- /dev/null +++ b/tests/test_backend_opt_in.py @@ -0,0 +1,78 @@ +"""Explicit backend selection + disable switch for ambient-key auto-detection (#4361). + +`detect_backend()` picks a cloud provider purely from whichever API key is set +in the environment, and community labeling additionally falls back to `claude -p` +when the CLI is on PATH. Keys in a developer environment are often there for +other tools, so a bare `graphify extract .` could send uncached documents to a +provider the user did not intend. + +These tests pin the additions that put the user back in control: + +- `GRAPHIFY_BACKEND=` selects a backend explicitly, overriding ambient keys; +- `GRAPHIFY_BACKEND=none` disables every LLM backend, including the `claude -p` + fallback, so the AST pass keeps working; +- auto-detection reports the exact environment variable that selected it. +""" +from __future__ import annotations + +import pytest + +from graphify import llm + +DISABLE_SENTINELS = ["none", "off", "disable", "disabled"] + + +def test_detect_backend_with_source_names_the_ambient_variable(monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "ambient") + backend, source = llm.detect_backend_with_source() + assert backend == "gemini" + assert source == "auto-detected from GEMINI_API_KEY" + assert llm.detect_backend() == "gemini" + + +def test_graphify_backend_selects_explicitly(monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "ambient") + monkeypatch.setenv("GRAPHIFY_BACKEND", "openai") + backend, source = llm.detect_backend_with_source() + assert backend == "openai", "GRAPHIFY_BACKEND must win over an ambient key" + assert source == "GRAPHIFY_BACKEND=openai" + + +@pytest.mark.parametrize("sentinel", DISABLE_SENTINELS) +def test_graphify_backend_none_disables(monkeypatch, sentinel): + monkeypatch.setenv("GEMINI_API_KEY", "ambient") + monkeypatch.setenv("GRAPHIFY_BACKEND", sentinel) + assert llm.detect_backend() is None + assert llm.backend_disabled_by_env() is True + _, source = llm.detect_backend_with_source() + assert source == f"GRAPHIFY_BACKEND={sentinel}" + + +def test_backend_not_disabled_when_unset(monkeypatch): + assert llm.backend_disabled_by_env() is False + + +def test_extract_files_direct_refuses_cleanly_when_disabled(monkeypatch): + monkeypatch.setenv("GRAPHIFY_BACKEND", "none") + with pytest.raises(ValueError, match="GRAPHIFY_BACKEND=none"): + llm.extract_files_direct([], backend=None) + + +def test_generate_community_labels_skips_claude_cli_when_disabled(monkeypatch): + monkeypatch.setenv("GRAPHIFY_BACKEND", "none") + monkeypatch.setattr(llm, "_claude_cli_available", lambda: True) + + calls = {"n": 0} + + def _spy(*args, **kwargs): # pragma: no cover - must not run + calls["n"] += 1 + return {} + + monkeypatch.setattr(llm, "label_communities", _spy) + + labels, source = llm.generate_community_labels( + None, {0: ["a"], 1: ["b"]}, quiet=True + ) + assert source == "placeholder" + assert labels == {0: "Community 0", 1: "Community 1"} + assert calls["n"] == 0, "no backend may be contacted when GRAPHIFY_BACKEND=none"