From e91d30823f65ba722fa01c8943c6e5516665fb20 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:42:29 +0800 Subject: [PATCH] Add MiniMax extraction backend --- README.md | 6 +++++- graphify/llm.py | 19 +++++++++++++++---- tests/test_llm_backends.py | 30 ++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0459f57cf..9532499a8 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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) @@ -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/-callflow.html graphify export callflow-html --max-sections 8 # cap generated architecture sections diff --git a/graphify/llm.py b/graphify/llm.py index 8c34cde6b..c87915888 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -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"), @@ -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." ) @@ -2727,7 +2738,7 @@ 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 @@ -2735,7 +2746,7 @@ def detect_backend() -> str | None: 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"): @@ -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 diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index c1392ded5..8276cc9ea 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -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", @@ -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") @@ -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 @@ -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"), ]) @@ -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(