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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,9 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-4.1-mini`) |
| `DEEPSEEK_API_KEY` | DeepSeek backend | `--backend deepseek` |
| `MOONSHOT_API_KEY` | Kimi Code backend | `--backend kimi` |
| `MINIMAX_API_KEY` | MiniMax OpenAI-compatible backend | `--backend minimax` |
| `MINIMAX_BASE_URL` | MiniMax regional OpenAI-compatible endpoint | `--backend minimax` (default: `https://api.minimax.io/v1`; China: `https://api.minimaxi.com/v1`) |
| `MINIMAX_MODEL` or `GRAPHIFY_MINIMAX_MODEL` | MiniMax model name | `--backend minimax` (default: `MiniMax-M3`; `MiniMax-M2.7` is also supported) |
| `OLLAMA_BASE_URL` | Ollama local inference URL | `--backend ollama` (default: `http://localhost:11434`) |
| `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 |
Expand Down Expand Up @@ -715,7 +718,7 @@ graphify antigravity install # .agents/rules + .agents/workflows (Google A
graphify antigravity uninstall

graphify extract ./docs # headless LLM extraction for CI (no IDE needed)
graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock, or claude-cli
graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, minimax, claude, openai, deepseek, ollama, bedrock, or claude-cli
graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL) - no API key needed for loopback
OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # any OpenAI-compatible server (llama.cpp, vLLM, LM Studio)
Expand All @@ -740,6 +743,7 @@ graphify extract ./docs --force # overwrite graph.json even if ne
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key)
graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora
MINIMAX_API_KEY=... graphify extract ./docs --backend minimax # MiniMax-M3 by default; set GRAPHIFY_MINIMAX_MODEL for MiniMax-M2.7

graphify export callflow-html # graphify-out/<project>-callflow.html
graphify export callflow-html --max-sections 8 # cap generated architecture sections
Expand Down
6 changes: 4 additions & 2 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3321,7 +3321,7 @@ def _invalidate_file_manifest_for_db_graph() -> None:
pass
stages.mark("write")
cost = _estimate_cost(
backend, merged["input_tokens"], merged["output_tokens"]
backend, merged["input_tokens"], merged["output_tokens"], model=model
)
print(
f"[graphify extract] wrote {graph_json_path} — "
Expand Down Expand Up @@ -3488,7 +3488,9 @@ def _invalidate_file_manifest_for_db_graph() -> None:
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)

cost = _estimate_cost(backend, merged["input_tokens"], merged["output_tokens"])
cost = _estimate_cost(
backend, merged["input_tokens"], merged["output_tokens"], model=model
)
print(
f"[graphify extract] wrote {graph_json_path}: "
f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges, "
Expand Down
54 changes: 45 additions & 9 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ def _resolve_ollama_base_url(default: str) -> str:
"temperature": None, # kimi-k2.6 enforces its own fixed temperature; sending any value raises 400
"max_tokens": 16384,
},
"minimax": {
# MINIMAX_BASE_URL selects the regional OpenAI-compatible endpoint.
"base_url": os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1"),
"default_model": os.environ.get("MINIMAX_MODEL", "MiniMax-M3"),
"env_key": "MINIMAX_API_KEY",
"model_env_key": "GRAPHIFY_MINIMAX_MODEL",
"pricing": {"input": 0.60, "output": 2.40}, # USD per 1M tokens
"model_pricing": {
"MiniMax-M3": {"input": 0.60, "output": 2.40},
"MiniMax-M2.7": {"input": 0.30, "output": 1.20},
},
"temperature": 0,
"max_tokens": 16384,
"vision": True,
"model_vision": {"MiniMax-M3": True, "MiniMax-M2.7": False},
},
"ollama": {
"base_url": _resolve_ollama_base_url("http://localhost:11434/v1"),
"default_model": os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"),
Expand Down Expand Up @@ -840,7 +856,7 @@ def _strip_pixels(refs: list[_ImageRef]) -> list[_ImageRef]:
return [replace(r, raw=None) for r in refs]


def _backend_supports_vision(backend: str) -> bool:
def _backend_supports_vision(backend: str, model: str | None = None) -> bool:
"""Whether `backend`'s configured model can see images.

Ollama is special-cased: its default model is text-only, so vision is
Expand All @@ -849,7 +865,13 @@ def _backend_supports_vision(backend: str) -> bool:
"""
if backend == "ollama":
return os.environ.get("GRAPHIFY_OLLAMA_VISION", "").strip() == "1"
return bool(BACKENDS.get(backend, {}).get("vision", False))
cfg = BACKENDS.get(backend, {})
model_vision = cfg.get("model_vision", {})
if model_vision:
model = model or _default_model_for_backend(backend)
if model in model_vision:
return bool(model_vision[model])
return bool(cfg.get("vision", False))


def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str:
Expand Down Expand Up @@ -1177,6 +1199,9 @@ def _call_openai_compat(
# Kimi-k2.6 is a reasoning model — disable thinking so content isn't empty
elif "moonshot" in base_url:
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
# MiniMax-M2.7 always reasons, so the global opt-out must not disable it.
elif backend == "minimax" and model == "MiniMax-M2.7":
pass
# Opt-in only: disable thinking for reasoning models like deepseek-v4-flash
# (#1621). Not a default — see _thinking_disabled_via_env for the tradeoff.
elif _thinking_disabled_via_env():
Expand Down Expand Up @@ -1676,7 +1701,7 @@ def extract_files_direct(
if backend is None:
raise ValueError(
"No LLM backend configured. Set one of: GEMINI_API_KEY, ANTHROPIC_API_KEY, "
"OPENAI_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY, "
"OPENAI_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY, MINIMAX_API_KEY, "
"AZURE_OPENAI_API_KEY+AZURE_OPENAI_ENDPOINT, OLLAMA_BASE_URL, "
"or AWS credentials. Pass backend= explicitly to select a provider."
)
Expand Down Expand Up @@ -1709,7 +1734,7 @@ def extract_files_direct(
# (vision backends) or as a text reference node (everything else).
text_files, image_files = _partition_semantic_files(files)
user_msg = _read_files(text_files, root)
vision = _backend_supports_vision(backend)
vision = _backend_supports_vision(backend, mdl)
# Only base64 (inline) vision backends need the bytes loaded + size-capped;
# path-based backends (claude-cli) and non-vision backends do not.
read_bytes = vision and backend not in _PATH_IMAGE_BACKENDS
Expand Down Expand Up @@ -2631,6 +2656,9 @@ def _rec(inp, out) -> None:
kwargs["extra_body"] = cfg["extra_body"]
elif "moonshot" in cfg["base_url"]:
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
# MiniMax-M2.7 always reasons, so the global opt-out must not disable it.
elif backend == "minimax" and mdl == "MiniMax-M2.7":
pass
elif _thinking_disabled_via_env():
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
resp = client.chat.completions.create(**kwargs)
Expand All @@ -2642,11 +2670,19 @@ def _rec(inp, out) -> None:
return resp.choices[0].message.content or ""


def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
def estimate_cost(
backend: str,
input_tokens: int,
output_tokens: int,
model: str | None = None,
) -> float:
"""Estimate USD cost for a given token count using published pricing."""
if backend not in BACKENDS:
return 0.0
p = BACKENDS[backend]["pricing"]
cfg = BACKENDS[backend]
model_pricing = cfg.get("model_pricing", {})
selected_model = model or _default_model_for_backend(backend)
p = model_pricing.get(selected_model, cfg["pricing"])
return (input_tokens * p["input"] + output_tokens * p["output"]) / 1_000_000


Expand Down Expand Up @@ -2727,15 +2763,15 @@ 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()

17 callers depend on it (afferent coupling).

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

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

17 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).
Priority: gemini → kimi → minimax → 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
--backend ollama explicitly) to route to the local model.
"""
for backend in ("gemini", "kimi", "claude", "openai", "deepseek"):
for backend in ("gemini", "kimi", "minimax", "claude", "openai", "deepseek"):
if _get_backend_api_key(backend):
return backend
if _get_backend_api_key("azure") and os.environ.get("AZURE_OPENAI_ENDPOINT"):
Expand All @@ -2751,7 +2787,7 @@ def detect_backend() -> str | None:
_validate_ollama_base_url(ollama_url)
return "ollama"
for name in BACKENDS:
if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"):
if name not in ("gemini", "kimi", "minimax", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"):
if _get_backend_api_key(name):
return name
return None
Expand Down
65 changes: 63 additions & 2 deletions tests/test_llm_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ def _clear_backend_env(monkeypatch):
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MOONSHOT_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_BASE_URL",
"MINIMAX_MODEL",
"GRAPHIFY_MINIMAX_MODEL",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
Expand Down Expand Up @@ -66,6 +70,39 @@ def test_gemini_accepts_google_api_key(monkeypatch):
assert llm._get_backend_api_key("gemini") == "google-key"


def test_minimax_backend_detected(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("MINIMAX_API_KEY", "minimax-key")

assert llm.detect_backend() == "minimax"
assert llm._get_backend_api_key("minimax") == "minimax-key"
assert llm._default_model_for_backend("minimax") == "MiniMax-M3"


def test_minimax_model_override(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_MINIMAX_MODEL", "MiniMax-M2.7")

assert llm._default_model_for_backend("minimax") == "MiniMax-M2.7"


def test_minimax_backend_capabilities_are_model_specific():
assert llm._backend_supports_vision("minimax", "MiniMax-M3") is True
assert llm._backend_supports_vision("minimax", "MiniMax-M2.7") is False


def test_minimax_model_pricing(monkeypatch):
assert llm.estimate_cost(
"minimax", 1_000_000, 500_000, model="MiniMax-M2.7"
) == pytest.approx(0.90)
assert llm.estimate_cost(
"minimax", 1_000_000, 500_000, model="MiniMax-M3"
) == pytest.approx(1.80)

monkeypatch.setenv("GRAPHIFY_MINIMAX_MODEL", "MiniMax-M2.7")
assert llm.estimate_cost("minimax", 1_000_000, 500_000) == pytest.approx(0.90)


def test_backend_detection_prefers_gemini(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
Expand Down Expand Up @@ -638,6 +675,28 @@ def test_deepseek_thinking_disabled_via_env(monkeypatch):
assert captured["extra_body"] == {"thinking": {"type": "disabled"}}


def test_minimax_m27_thinking_cannot_be_disabled(monkeypatch):
monkeypatch.setenv("GRAPHIFY_DISABLE_THINKING", "1")
captured = _install_capturing_openai(monkeypatch)

llm._call_openai_compat(
"https://api.minimax.io/v1", "sk", "MiniMax-M2.7",
"u", temperature=0, max_completion_tokens=8192, backend="minimax",
)

assert "extra_body" not in captured


def test_minimax_m27_call_llm_thinking_cannot_be_disabled(monkeypatch):
monkeypatch.setenv("GRAPHIFY_DISABLE_THINKING", "1")
monkeypatch.setattr(llm, "_get_backend_api_key", lambda _backend: "sk")
captured = _install_capturing_openai(monkeypatch)

llm._call_llm("u", backend="minimax", model="MiniMax-M2.7")

assert "extra_body" not in captured


def test_explicit_extra_body_wins_over_thinking_env(monkeypatch):
# A provider-supplied extra_body is an explicit request-shape choice and must
# take precedence over the env toggle.
Expand Down Expand Up @@ -962,7 +1021,7 @@ def test_native_extraction_prompt_matches_skill_spec_on_hyperedges():
assert shared in llm._EXTRACTION_SYSTEM, "native prompt drifted from the skill hyperedge wording"


# --- *_BASE_URL env overrides for kimi / gemini / deepseek (#1458) -------------
# --- *_BASE_URL env overrides for OpenAI-compatible backends (#1458) -----------
# BACKENDS reads the env at import time, so each case runs in a fresh interpreter
# (subprocess) to avoid reload contamination of the test session.
import subprocess
Expand All @@ -983,6 +1042,7 @@ def _backend_base_url(backend: str, env_extra: dict) -> str:

@pytest.mark.parametrize("backend,env_var,override", [
("kimi", "KIMI_BASE_URL", "https://proxy.example/kimi/v1"),
("minimax", "MINIMAX_BASE_URL", "https://proxy.example/minimax/v1"),
("gemini", "GEMINI_BASE_URL", "https://proxy.example/gemini"),
("deepseek", "DEEPSEEK_BASE_URL", "https://proxy.example/deepseek"),
])
Expand All @@ -992,12 +1052,13 @@ def test_base_url_env_overrides(backend, env_var, override):

@pytest.mark.parametrize("backend,default", [
("kimi", "https://api.moonshot.ai/v1"),
("minimax", "https://api.minimax.io/v1"),
("gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"),
("deepseek", "https://api.deepseek.com"),
])
def test_base_url_defaults_without_env(backend, default):
# Ensure the override env vars are unset so the hardcoded default is used.
cleared = {k: "" for k in ("KIMI_BASE_URL", "GEMINI_BASE_URL", "DEEPSEEK_BASE_URL")}
cleared = {k: "" for k in ("KIMI_BASE_URL", "MINIMAX_BASE_URL", "GEMINI_BASE_URL", "DEEPSEEK_BASE_URL")}
# empty string would be falsy-but-set; delete instead by reconstructing env without them
env = {k: v for k, v in os.environ.items() if k not in cleared}
out = subprocess.run(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_provider_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ def test_detect_backend_custom_provider_after_builtins(monkeypatch):
}
})
monkeypatch.setenv("MY_CUSTOM_KEY", "test-key")
for key in ("GEMINI_API_KEY", "GOOGLE_API_KEY", "MOONSHOT_API_KEY", "ANTHROPIC_API_KEY",
"OPENAI_API_KEY", "DEEPSEEK_API_KEY", "OLLAMA_BASE_URL"):
for key in ("GEMINI_API_KEY", "GOOGLE_API_KEY", "MOONSHOT_API_KEY", "MINIMAX_API_KEY",
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY", "OLLAMA_BASE_URL"):
monkeypatch.delenv(key, raising=False)
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.delenv("AWS_REGION", raising=False)
Expand Down