diff --git a/README.md b/README.md index d83ea76c0..8b121814b 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d5..701aceb2d 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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}'. " diff --git a/graphify/llm.py b/graphify/llm.py index 75f637881..5ecd06db0 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -3111,21 +3111,44 @@ def _validate_ollama_base_url(url: str, *, warn: bool = True) -> None: def detect_backend() -> str | None: """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, diff --git a/tests/test_extract_cli.py b/tests/test_extract_cli.py index 4c9fb445f..8e69c077f 100644 --- a/tests/test_extract_cli.py +++ b/tests/test_extract_cli.py @@ -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) @@ -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 diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index 4480eff62..696e05ecc 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -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) @@ -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")