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
19 changes: 15 additions & 4 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ 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
"temperature": 0,
"max_tokens": 16384,
"vision": True,
},
"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 @@ -1676,7 +1687,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 @@ -2727,15 +2738,15 @@ 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).
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 +2762,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
30 changes: 28 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,26 @@ 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_supports_vision():
assert llm._backend_supports_vision("minimax") is True


def test_backend_detection_prefers_gemini(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
Expand Down Expand Up @@ -962,7 +986,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 +1007,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 +1017,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