Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `AZURE_OPENAI_API_VERSION` | Azure API version override | optional — default `2024-12-01-preview` |
| `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-4o` |
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) |
| `GRAPHIFY_BACKEND` | Global backend override (e.g. `bedrock`, `ollama`, `claude`, `none`, `auto`) | optional — overrides automatic detection |
| `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag |
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files |
| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, Anthropic SDK, and Bedrock backends (default: 600) | optional — also `--api-timeout` flag |
Expand Down
6 changes: 5 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3615,7 +3615,11 @@ def _parse_float(name: str, raw: str) -> float:
)
needs_llm = bool(semantic_files) or dedup_llm
if backend is None and needs_llm:
backend = _detect_backend()
try:
backend = _detect_backend()
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
if backend is not None and backend not in _BACKENDS:
print(
f"error: unknown backend '{backend}'. "
Expand Down
39 changes: 31 additions & 8 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3111,21 +3111,44 @@ def _validate_ollama_base_url(url: str, *, warn: bool = True) -> None:
def detect_backend() -> str | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiondetect_backend()

28 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Return the name of whichever backend has an API key set, or None.

Priority: gemini → kimi → claude → openai → deepseek → azure → bedrock → ollama (last, opt-in).

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
— see security finding F-002/F-029. Setting OLLAMA_BASE_URL alongside a paid
key now keeps you on the paid backend; remove the paid key (or pass
If GRAPHIFY_BACKEND is set, it overrides automatic detection:
- valid backend name (e.g. "bedrock", "ollama", "claude"): selects that backend
- "none" or "off": returns None (disables backend detection)
- "auto": continues to normal automatic detection
- unknown value: raises ValueError

Automatic detection priority:
gemini → kimi → claude → openai → deepseek → azure → ollama → custom providers.

AWS Bedrock is intentionally NOT auto-detected from ambient AWS_PROFILE /
AWS_REGION variables to prevent accidental spend or crashes when boto3 is
missing; select it explicitly via --backend bedrock or GRAPHIFY_BACKEND=bedrock (#3300).

Ollama is intentionally checked LAST among built-in providers so a paid API key
(Anthropic/OpenAI/etc.) is never silently shadowed by an incidental OLLAMA_BASE_URL
in the environment — see security finding F-002/F-029. Setting OLLAMA_BASE_URL alongside
a paid key keeps you on the paid backend; remove the paid key (or pass
--backend ollama explicitly) to route to the local model.
"""
raw_override = os.environ.get("GRAPHIFY_BACKEND", "").strip()
if raw_override:
override = raw_override.lower()
if override in ("none", "off"):
return None
if override != "auto":
matched = next((b for b in BACKENDS if b.lower() == override), None)
if matched is None:
raise ValueError(
f"unknown backend '{raw_override}' in GRAPHIFY_BACKEND. "
f"Available: {', '.join(sorted(BACKENDS))}"
)
return matched

for backend in ("gemini", "kimi", "claude", "openai", "deepseek"):
if _get_backend_api_key(backend):
return backend
if _get_backend_api_key("azure") and os.environ.get("AZURE_OPENAI_ENDPOINT"):
return "azure"
if os.environ.get("AWS_PROFILE") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"):
return "bedrock"
# 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,
Expand Down
74 changes: 74 additions & 0 deletions tests/test_extract_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ def _clear_backend_keys(monkeypatch):
"AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID",
# ollama: a set OLLAMA_BASE_URL triggers backend detection
"OLLAMA_BASE_URL",
"GRAPHIFY_BACKEND",
):
monkeypatch.delenv(key, raising=False)

Expand Down Expand Up @@ -1668,3 +1669,76 @@ def _straying_extraction(paths, **kwargs):
assert "phantom_ts" not in ids, (
f"stray attributed to a nonexistent path became a phantom node: {ids}"
)


def test_cli_explicit_backend_overrides_graphify_backend(monkeypatch, tmp_path):
"""Explicit --backend must override GRAPHIFY_BACKEND."""
corpus = _code_only_corpus(tmp_path)
out_dir = tmp_path / "out"
_clear_backend_keys(monkeypatch)
monkeypatch.setenv("GRAPHIFY_BACKEND", "bedrock")

captured_backend = []
def fake_extract_parallel(*args, **kwargs):
captured_backend.append(kwargs.get("backend"))
on_chunk = kwargs.get("on_chunk_done")
chunk = {
"nodes": [{"id": "doc_a", "label": "Doc A", "file_type": "document", "source_file": "doc.md"}],
"edges": [], "hyperedges": [], "input_tokens": 1, "output_tokens": 1,
}
if on_chunk:
on_chunk(0, 1, chunk)
return chunk

(corpus / "doc.md").write_text("# Doc\n")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", fake_extract_parallel)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys, "argv",
["graphify", "extract", str(corpus), "--backend", "openai", "--out", str(out_dir)],
)
try:
mainmod.main()
except SystemExit as exc:
assert exc.code in (None, 0)
assert captured_backend == ["openai"]


def test_cli_invalid_graphify_backend_exits_1(monkeypatch, tmp_path, capsys):
"""Invalid GRAPHIFY_BACKEND must fail loudly with exit code 1 and a clean error."""
corpus = _make_corpus(tmp_path) # includes markdown
out_dir = tmp_path / "out"
_clear_backend_keys(monkeypatch)
monkeypatch.setenv("GRAPHIFY_BACKEND", "invalid-backend")
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 "error: unknown backend 'invalid-backend' in GRAPHIFY_BACKEND" in err


def test_extract_with_ambient_aws_env_does_not_select_bedrock(monkeypatch, tmp_path, capsys):
"""Ambient AWS environment variables must not select Bedrock for semantic extraction."""
corpus = _make_corpus(tmp_path) # includes markdown
out_dir = tmp_path / "out"
_clear_backend_keys(monkeypatch)
monkeypatch.setenv("AWS_REGION", "us-east-1")
monkeypatch.setenv("AWS_PROFILE", "default")
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
122 changes: 122 additions & 0 deletions tests/test_llm_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ def _clear_backend_env(monkeypatch):
"DEEPSEEK_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
"GRAPHIFY_BACKEND",
"OLLAMA_BASE_URL",
"OLLAMA_HOST",
"AWS_PROFILE",
"AWS_REGION",
"AWS_DEFAULT_REGION",
"AWS_ACCESS_KEY_ID",
):
monkeypatch.delenv(env_key, raising=False)

Expand Down Expand Up @@ -84,6 +91,121 @@ def test_openai_backend_detected(monkeypatch):
assert llm._get_backend_api_key("openai") == "openai-key"


def test_detect_backend_ignores_ambient_aws_region(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AWS_REGION", "us-east-1")
assert llm.detect_backend() is None


def test_detect_backend_ignores_ambient_aws_profile(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AWS_PROFILE", "my-profile")
assert llm.detect_backend() is None


def test_detect_backend_ignores_ambient_aws_default_region(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-west-2")
assert llm.detect_backend() is None


def test_detect_backend_aws_vars_do_not_shadow_ollama(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AWS_REGION", "us-east-1")
monkeypatch.setenv("AWS_PROFILE", "default")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
assert llm.detect_backend() == "ollama"


def test_detect_backend_aws_vars_do_not_shadow_custom_provider(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AWS_REGION", "us-east-1")
monkeypatch.setenv("AWS_PROFILE", "default")
monkeypatch.setattr(llm, "BACKENDS", {
**llm.BACKENDS,
"mycustom": {
"base_url": "http://localhost:8000/v1",
"default_model": "custom-model",
"env_key": "MYCUSTOM_API_KEY",
"pricing": {"input": 0.0, "output": 0.0},
}
})
monkeypatch.setenv("MYCUSTOM_API_KEY", "custom-key")
assert llm.detect_backend() == "mycustom"


def test_detect_backend_graphify_backend_bedrock(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_BACKEND", "bedrock")
assert llm.detect_backend() == "bedrock"


def test_detect_backend_graphify_backend_ollama(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_BACKEND", "ollama")
assert llm.detect_backend() == "ollama"


def test_detect_backend_graphify_backend_custom_provider(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setattr(llm, "BACKENDS", {
**llm.BACKENDS,
"mycustom": {
"base_url": "http://localhost:8000/v1",
"default_model": "custom-model",
"env_key": "MYCUSTOM_API_KEY",
"pricing": {"input": 0.0, "output": 0.0},
}
})
monkeypatch.setenv("GRAPHIFY_BACKEND", "mycustom")
assert llm.detect_backend() == "mycustom"


def test_detect_backend_graphify_backend_none(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv("GRAPHIFY_BACKEND", "none")
assert llm.detect_backend() is None


def test_detect_backend_graphify_backend_off(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv("GRAPHIFY_BACKEND", "off")
assert llm.detect_backend() is None


def test_detect_backend_graphify_backend_auto(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv("GRAPHIFY_BACKEND", "auto")
assert llm.detect_backend() == "openai"


def test_detect_backend_graphify_backend_invalid_raises(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_BACKEND", "nonexistent-provider")
with pytest.raises(ValueError) as excinfo:
llm.detect_backend()
assert "unknown backend 'nonexistent-provider' in GRAPHIFY_BACKEND" in str(excinfo.value)


def test_extract_files_direct_explicit_bedrock_bypasses_detection(tmp_path, monkeypatch):
_clear_backend_env(monkeypatch)
source = tmp_path / "note.md"
source.write_text("# Note\n")
fake_result = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 1, "output_tokens": 1}

def _fail_if_called():
raise AssertionError("detect_backend() should not have been called")

monkeypatch.setattr(llm, "detect_backend", _fail_if_called)
with patch("graphify.llm._call_bedrock", return_value=fake_result) as call:
res = llm.extract_files_direct([source], backend="bedrock", root=tmp_path)
assert res is fake_result
assert call.call_count == 1


def test_extract_files_direct_routes_gemini_through_openai_compat(tmp_path, monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("GOOGLE_API_KEY", "google-key")
Expand Down
Loading