feat(llm): opt-in backend selection, disable switch, and honest auto-detect (#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.

Add explicit control without breaking existing auto-detect flows:

- GRAPHIFY_BACKEND=<name> selects a backend explicitly and wins over ambient
  keys; GRAPHIFY_BACKEND=none disables every LLM backend, including the label
  claude -p fallback. `graphify extract` then keeps the local AST pass and
  reports what it held back instead of failing or auto-selecting a provider.
- Auto-detection now reports the exact environment variable that selected the
  backend, so sending a corpus to a cloud provider is never a surprise.
- The claude -p labeling fallback is announced, and GRAPHIFY_BACKEND=none opts
  out of it (it is otherwise kept: silently downgrading good labels to
  "Community N" was the reason it exists).
- extract_files_direct raises a clear error naming GRAPHIFY_BACKEND when the
  library caller would otherwise auto-detect a disabled backend.

README data-residency note updated. detect_backend_with_source() now carries the
detection logic and names its source; detect_backend() delegates to it.

Note: this deliberately keeps auto-detection ON by default (the issue's
alternative #2) rather than flipping cloud use to opt-in, which would break
every existing key-less `graphify extract` flow. GRAPHIFY_BACKEND makes the
opt-in switch a one-liner for a follow-up if maintainers prefer it.
This commit is contained in:
N. Hariharan authored and nothariharan committed 2026-10-14 09:50:18 +05:30
1 parent 041771be72
commit 7e2467ea43
5 files changed
+209 -17

No files matched your search

+1 -1
View File
@@ -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=<name>`; 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.
+30 -4
View File
@@ -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,
+96 -11
View File
@@ -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:
+4 -1
View File
@@ -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())
+78
View File
@@ -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=<name>` 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"