mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-18 01:49:22 +00:00
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:
1 parent
041771be72
commit
7e2467ea43
5 files changed
+209
-17
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user