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/cli.py b/graphify/cli.py index 91df09672..5b1d01a2b 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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} — " @@ -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, " diff --git a/graphify/llm.py b/graphify/llm.py index 8c34cde6b..35b099265 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -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"), @@ -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 @@ -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: @@ -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(): @@ -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." ) @@ -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 @@ -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) @@ -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 @@ -2727,7 +2763,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 +2771,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 +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 diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index c1392ded5..d4966e992 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,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") @@ -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. @@ -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 @@ -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"), ]) @@ -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( diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 0366c13ff..b99196e5d 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -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)