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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
| `anthropic` | Anthropic Claude API (`--backend claude`, uses `ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` |
| `bedrock` | AWS Bedrock (uses IAM, no API key) | `uv tool install "graphifyy[bedrock]"` |
| `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` |
| `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`, or Entra ID with no key) | `uv tool install "graphifyy[azure]"` |
| `sql` | SQL schema extraction | `uv tool install "graphifyy[sql]"` |
| `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` |
| `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` |
Expand Down Expand Up @@ -510,8 +510,9 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `OLLAMA_MODEL` | Ollama model name | `--backend ollama` (default: auto-detect) |
| `GRAPHIFY_OLLAMA_NUM_CTX` | Override Ollama KV-cache window size | optional — auto-sized by default |
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | Minutes to keep Ollama model loaded | optional — set `0` to unload after each chunk |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` |
| `AZURE_OPENAI_ENDPOINT` | Azure resource endpoint URL | `--backend azure` (required alongside API key) |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` (omit when using Entra ID) |
| `AZURE_OPENAI_ENDPOINT` | Azure resource endpoint URL | `--backend azure` (always required) |
| `AZURE_OPENAI_AUTH_MODE` | Set to `entra` to authenticate via Entra ID instead of an API key | optional — required for resources with `disableLocalAuth` |
| `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) |
Expand Down
58 changes: 52 additions & 6 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ def _resolve_ollama_base_url(default: str) -> str:
"azure": {
# Azure OpenAI Service — uses AzureOpenAI SDK client, not the standard
# OpenAI client, so it has its own call path (_call_azure).
# Required env vars: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT.
# Required env vars: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT — or
# AZURE_OPENAI_AUTH_MODE=entra with AZURE_OPENAI_ENDPOINT, which
# authenticates via DefaultAzureCredential and needs no key (the
# only option for resources with disableLocalAuth: true).
# Optional: AZURE_OPENAI_API_VERSION (defaults to 2024-12-01-preview),
# AZURE_OPENAI_DEPLOYMENT or GRAPHIFY_AZURE_MODEL (deployment name).
# base_url is intentionally absent — prevents accidental routing through
Expand Down Expand Up @@ -1566,13 +1569,45 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
return result


_AZURE_ENTRA_SCOPE = "https://cognitiveservices.azure.com/.default"


def _azure_uses_entra() -> bool:
"""True when AZURE_OPENAI_AUTH_MODE selects Entra ID (AAD) auth over an API key.

Azure OpenAI resources provisioned with ``disableLocalAuth: true`` cannot
issue an API key at all, so key-based auth is not merely discouraged there —
it is unavailable. Accepts "entra" and the older "aad" spelling.
"""
return os.environ.get("AZURE_OPENAI_AUTH_MODE", "").strip().lower() in ("entra", "aad")


def _azure_token_provider():
"""Return a bearer-token provider backed by the standard Azure credential chain.

DefaultAzureCredential resolves, in order: environment variables, workload
identity, managed identity, Azure CLI, Azure PowerShell, and Azure Developer
CLI — so the same code path covers a developer laptop signed in with `az
login` and a container running under a managed identity. Token refresh is
handled by the provider.
"""
try:
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
except ImportError as exc:
raise ImportError(
"Azure OpenAI Entra ID auth requires azure-identity. "
"Run: pip install graphifyy[azure]"
) from exc
return get_bearer_token_provider(DefaultAzureCredential(), _AZURE_ENTRA_SCOPE)


def _azure_client(api_key: str, endpoint: str):

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 regression_azure_client()

6 callers depend on it (afferent coupling).

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

"""Construct an AzureOpenAI client with env-driven api_version and timeout."""
try:
from openai import AzureOpenAI
except ImportError as exc:
raise ImportError(
"Azure OpenAI requires the openai package. Run: pip install openai"
"Azure OpenAI requires the openai package. Run: pip install graphifyy[azure]"
) from exc
api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview").strip()
timeout_raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip()
Expand All @@ -1584,8 +1619,15 @@ def _azure_client(api_key: str, endpoint: str):
timeout_s = v
except ValueError:
pass
return AzureOpenAI(api_key=api_key, azure_endpoint=endpoint, api_version=api_version, timeout=timeout_s,
max_retries=_resolve_max_retries())
common = {
"azure_endpoint": endpoint,
"api_version": api_version,
"timeout": timeout_s,
"max_retries": _resolve_max_retries(),
}
if _azure_uses_entra():
return AzureOpenAI(azure_ad_token_provider=_azure_token_provider(), **common)
return AzureOpenAI(api_key=api_key, **common)


def _call_azure(
Expand Down Expand Up @@ -1735,7 +1777,9 @@ def extract_files_direct(
file=sys.stderr,
)
key = "ollama"
if not key and backend not in ("bedrock", "claude-cli"):
# bedrock and claude-cli authenticate ambiently (AWS credential chain, Claude
# Code session); azure does too when AZURE_OPENAI_AUTH_MODE selects Entra ID.
if not key and backend not in ("bedrock", "claude-cli") and not (backend == "azure" and _azure_uses_entra()):
raise ValueError(
f"No API key for backend '{backend}'. "
f"Set {_format_backend_env_keys(backend)} or pass api_key=."
Expand Down Expand Up @@ -2783,7 +2827,9 @@ def detect_backend() -> str | None:
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"):
# Entra ID auth issues no API key (and disableLocalAuth resources cannot have
# one), so the endpoint plus an explicit auth mode is the whole credential.
if (_get_backend_api_key("azure") or _azure_uses_entra()) 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"
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ video = ["faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9"]
kimi = ["openai", "tiktoken"]
ollama = ["openai"]
bedrock = ["boto3"]
# azure-identity is only needed for AZURE_OPENAI_AUTH_MODE=entra; the key-based
# path needs openai alone, so the import is deferred and its absence only raises
# when Entra auth is actually selected.
azure = ["openai", "tiktoken", "azure-identity"]
anthropic = ["anthropic"]
gemini = ["openai", "tiktoken"]
openai = ["openai", "tiktoken"]
Expand All @@ -85,7 +89,7 @@ pascal = ["tree-sitter-pascal"]
# avoids breaking the default `uv tool install graphifyy` for everyone (#1104).
dm = ["tree-sitter-dm"]
terraform = ["tree-sitter-hcl"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "azure-identity", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"]

[project.scripts]
graphify = "graphify.__main__:main"
Expand Down
113 changes: 113 additions & 0 deletions tests/test_llm_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def _clear_backend_env(monkeypatch):
"DEEPSEEK_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_AUTH_MODE",
):
monkeypatch.delenv(env_key, raising=False)

Expand Down Expand Up @@ -1162,3 +1163,115 @@ def create(self, **_):
llm._call_llm("hi", backend="kimi")
assert ctor_kwargs.get("timeout") == 1.0, ctor_kwargs
assert ctor_kwargs.get("max_retries", 0) >= 5, ctor_kwargs


# ---------------------------------------------------------------------------
# Azure backend — Entra ID (AAD) auth
# ---------------------------------------------------------------------------


def _install_fake_azure_identity(monkeypatch):
"""Inject a stub azure.identity so the Entra path runs without the real SDK."""
import sys
import types

captured: dict = {}

class _FakeCredential:
def __init__(self, *_, **__):
captured["credential_built"] = True

def _fake_get_bearer_token_provider(credential, *scopes):
captured["scopes"] = scopes
return lambda: "fake-bearer-token"

identity_module = types.ModuleType("azure.identity")
identity_module.DefaultAzureCredential = _FakeCredential
identity_module.get_bearer_token_provider = _fake_get_bearer_token_provider
azure_module = types.ModuleType("azure")
azure_module.identity = identity_module
monkeypatch.setitem(sys.modules, "azure", azure_module)
monkeypatch.setitem(sys.modules, "azure.identity", identity_module)
return captured


def test_azure_client_uses_token_provider_when_auth_mode_entra(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "entra")
captured_client = _install_fake_azure_openai(monkeypatch, _fake_openai_response("{}"))
captured_identity = _install_fake_azure_identity(monkeypatch)

llm._azure_client("", "https://my-resource.openai.azure.com/")

init_kwargs = captured_client["init_kwargs"]
assert callable(init_kwargs.get("azure_ad_token_provider"))
assert "api_key" not in init_kwargs, "no API key may be sent on the Entra path"
assert captured_identity["scopes"] == ("https://cognitiveservices.azure.com/.default",)


def test_azure_client_accepts_aad_spelling(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "AAD")
captured_client = _install_fake_azure_openai(monkeypatch, _fake_openai_response("{}"))
_install_fake_azure_identity(monkeypatch)

llm._azure_client("", "https://my-resource.openai.azure.com/")

assert callable(captured_client["init_kwargs"].get("azure_ad_token_provider"))


def test_azure_client_still_uses_api_key_by_default(monkeypatch):
_clear_backend_env(monkeypatch)
captured = _install_fake_azure_openai(monkeypatch, _fake_openai_response("{}"))

llm._azure_client("test-key", "https://my-resource.openai.azure.com/")

init_kwargs = captured["init_kwargs"]
assert init_kwargs.get("api_key") == "test-key"
assert "azure_ad_token_provider" not in init_kwargs


def test_detect_backend_returns_azure_with_entra_and_no_key(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://my-resource.openai.azure.com/")
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "entra")

assert llm.detect_backend() == "azure"
assert llm._get_backend_api_key("azure") == ""


def test_detect_backend_entra_still_requires_endpoint(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "entra")

assert llm.detect_backend() != "azure"


def test_azure_entra_reports_missing_azure_identity(monkeypatch):
import sys

_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "entra")
_install_fake_azure_openai(monkeypatch, _fake_openai_response("{}"))
monkeypatch.setitem(sys.modules, "azure.identity", None)

with pytest.raises(ImportError, match=r"graphifyy\[azure\]"):
llm._azure_client("", "https://my-resource.openai.azure.com/")


def test_azure_entra_dispatch_does_not_require_an_api_key(tmp_path, monkeypatch):
# The no-key guard exempts bedrock and claude-cli; azure under Entra ID must
# be exempt too, otherwise the dispatcher rejects the call before it ever
# reaches _azure_client.
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://my-resource.openai.azure.com/")
monkeypatch.setenv("AZURE_OPENAI_AUTH_MODE", "entra")
source = tmp_path / "note.md"
source.write_text("# Architecture\n")
result = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 1, "output_tokens": 1}

with patch("graphify.llm._call_azure", return_value=result) as call:
assert llm.extract_files_direct([source], backend="azure", root=tmp_path) is result

assert call.call_args.args[0] == "", "no key is resolved on the Entra path"
assert call.call_args.args[1] == "https://my-resource.openai.azure.com/"