From f1531a0b6c5fdfdad3ecc3c7d5b9c9231d8361d8 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:10:58 +0800 Subject: [PATCH] Add MiniMax M3 and M2.7 model metadata --- .../patch-20260713065517820226.json | 4 + docs/config/models.md | 25 +++ .../metrics/default_metrics_processor.py | 48 ++++- .../model_cost_registry.py | 195 +++++++++++++++++- tests/unit/model_cost_registry/__init__.py | 4 + .../test_model_cost_registry.py | 124 +++++++++++ 6 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 .semversioner/next-release/patch-20260713065517820226.json create mode 100644 tests/unit/model_cost_registry/__init__.py create mode 100644 tests/unit/model_cost_registry/test_model_cost_registry.py diff --git a/.semversioner/next-release/patch-20260713065517820226.json b/.semversioner/next-release/patch-20260713065517820226.json new file mode 100644 index 0000000000..4b04ff0dad --- /dev/null +++ b/.semversioner/next-release/patch-20260713065517820226.json @@ -0,0 +1,4 @@ +{ + "type": "patch", + "description": "Add MiniMax M3 and M2.7 model cost metadata." +} diff --git a/docs/config/models.md b/docs/config/models.md index 0339b7f235..1354c5af59 100644 --- a/docs/config/models.md +++ b/docs/config/models.md @@ -28,6 +28,31 @@ embedding_models: See [Detailed Configuration](yaml.md) for more details on configuration. [View LiteLLM basic usage](https://docs.litellm.ai/docs/#basic-usage) for details on how models are called (The `model_provider` is the portion prior to `/` while the `model` is the portion following the `/`). +## MiniMax Text Models + +GraphRAG can call `MiniMax-M3` and `MiniMax-M2.7` through either supported API protocol. Use the `minimax` model provider for OpenAI-compatible requests and the `anthropic` model provider for Anthropic-compatible requests. + +| Region | Protocol | `model_provider` | `api_base` | +| --- | --- | --- | --- | +| Global | OpenAI-compatible | `minimax` | `https://api.minimax.io/v1` | +| China | OpenAI-compatible | `minimax` | `https://api.minimaxi.com/v1` | +| Global | Anthropic-compatible | `anthropic` | `https://api.minimax.io/anthropic` | +| China | Anthropic-compatible | `anthropic` | `https://api.minimaxi.com/anthropic` | + +Select the provider and base URL from the same row. LiteLLM appends `/chat/completions` to the OpenAI-compatible base or `/v1/messages` to the Anthropic-compatible base, so do not append `/v1` to an Anthropic-compatible base URL. + +```yaml +completion_models: + default_completion_model: + model_provider: minimax + model: MiniMax-M3 + auth_method: api_key + api_key: ${MINIMAX_API_KEY} + api_base: https://api.minimax.io/v1 +``` + +Use `MiniMax-M2.7` as the model value when that model is required. The cost registry recognizes both protocol aliases, including long-context and service-tier pricing for `MiniMax-M3`. + ## Model Selection Considerations GraphRAG has been most thoroughly tested with the gpt-4 series of models from OpenAI, including gpt-4 gpt-4-turbo, gpt-4o, and gpt-4o-mini. Our [arXiv paper](https://arxiv.org/abs/2404.16130), for example, performed quality evaluation using gpt-4-turbo. As stated above, non-OpenAI models are supported through the use of LiteLLM but the suite of gpt-4 series of models from OpenAI remain the most tested and supported suite of models for GraphRAG – in other words, these are the models we know best and can help resolve issues with. diff --git a/packages/graphrag-llm/graphrag_llm/metrics/default_metrics_processor.py b/packages/graphrag-llm/graphrag_llm/metrics/default_metrics_processor.py index 7249e701a1..398df3590b 100644 --- a/packages/graphrag-llm/graphrag_llm/metrics/default_metrics_processor.py +++ b/packages/graphrag-llm/graphrag_llm/metrics/default_metrics_processor.py @@ -78,8 +78,12 @@ def _process_lm_chat_completion( response: "LLMCompletionResponse", ) -> None: """Process LMChatCompletion metrics.""" - prompt_tokens = response.usage.prompt_tokens if response.usage else 0 - completion_tokens = response.usage.completion_tokens if response.usage else 0 + usage = response.usage + if usage is None: + return + + prompt_tokens = usage.prompt_tokens + completion_tokens = usage.completion_tokens total_tokens = prompt_tokens + completion_tokens if total_tokens > 0: @@ -89,13 +93,35 @@ def _process_lm_chat_completion( metrics["total_tokens"] = total_tokens model_id = f"{model_config.model_provider}/{model_config.model}" - model_costs = model_cost_registry.get_model_costs(model_id) + prompt_token_details = usage.prompt_tokens_details + cache_read_input_tokens = ( + prompt_token_details.cached_tokens if prompt_token_details else 0 + ) or 0 + usage_extra = usage.model_extra or {} + cache_read_input_tokens = int( + usage_extra.get("cache_read_input_tokens") or cache_read_input_tokens + ) + cache_creation_input_tokens = int( + usage_extra.get("cache_creation_input_tokens") or 0 + ) + service_tier = ( + response.service_tier + or input_args.get("service_tier") + or model_config.call_args.get("service_tier") + ) + calculated_costs = model_cost_registry.calculate_costs( + model_id, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cache_read_input_tokens=cache_read_input_tokens, + cache_creation_input_tokens=cache_creation_input_tokens, + service_tier=service_tier, + ) - if not model_costs: + if calculated_costs is None: return - input_cost = prompt_tokens * model_costs["input_cost_per_token"] - output_cost = completion_tokens * model_costs["output_cost_per_token"] + input_cost, output_cost = calculated_costs total_cost = input_cost + output_cost metrics["responses_with_cost"] = 1 @@ -119,12 +145,16 @@ def _process_lm_embedding_response( metrics["total_tokens"] = prompt_tokens model_id = f"{model_config.model_provider}/{model_config.model}" - model_costs = model_cost_registry.get_model_costs(model_id) + calculated_costs = model_cost_registry.calculate_costs( + model_id, + prompt_tokens=prompt_tokens, + completion_tokens=0, + ) - if not model_costs: + if calculated_costs is None: return - input_cost = prompt_tokens * model_costs["input_cost_per_token"] + input_cost, _ = calculated_costs metrics["responses_with_cost"] = 1 metrics["input_cost"] = input_cost metrics["total_cost"] = input_cost diff --git a/packages/graphrag-llm/graphrag_llm/model_cost_registry/model_cost_registry.py b/packages/graphrag-llm/graphrag_llm/model_cost_registry/model_cost_registry.py index 09a45bc750..8be988ed7c 100644 --- a/packages/graphrag-llm/graphrag_llm/model_cost_registry/model_cost_registry.py +++ b/packages/graphrag-llm/graphrag_llm/model_cost_registry/model_cost_registry.py @@ -3,17 +3,131 @@ """Model cost registry module.""" -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar, TypedDict, cast from litellm import model_cost -from typing_extensions import Self +from typing_extensions import Required, Self -class ModelCosts(TypedDict): +class ModelCosts(TypedDict, total=False): """Model costs.""" - input_cost_per_token: float - output_cost_per_token: float + input_cost_per_token: Required[float] + output_cost_per_token: Required[float] + input_cost_per_token_above_512k_tokens: float + output_cost_per_token_above_512k_tokens: float + input_cost_per_token_priority: float + output_cost_per_token_priority: float + input_cost_per_token_above_512k_tokens_priority: float + output_cost_per_token_above_512k_tokens_priority: float + cache_read_input_token_cost: float + cache_read_input_token_cost_above_512k_tokens: float + cache_read_input_token_cost_priority: float + cache_read_input_token_cost_above_512k_tokens_priority: float + cache_creation_input_token_cost: float + max_input_tokens: int + max_output_tokens: int + litellm_provider: str + mode: str + source: str + supported_modalities: list[str] + supports_adaptive_thinking: bool + supports_function_calling: bool + supports_multimodal: bool + supports_prompt_caching: bool + supports_reasoning: bool + supports_system_messages: bool + supports_tool_choice: bool + supports_video_input: bool + supports_vision: bool + + +_MINIMAX_M3_COSTS: ModelCosts = { + "input_cost_per_token": 0.3 / 1_000_000, + "output_cost_per_token": 1.2 / 1_000_000, + "input_cost_per_token_above_512k_tokens": 0.6 / 1_000_000, + "output_cost_per_token_above_512k_tokens": 2.4 / 1_000_000, + "input_cost_per_token_priority": 0.45 / 1_000_000, + "output_cost_per_token_priority": 1.8 / 1_000_000, + "input_cost_per_token_above_512k_tokens_priority": 0.9 / 1_000_000, + "output_cost_per_token_above_512k_tokens_priority": 3.6 / 1_000_000, + "cache_read_input_token_cost": 0.06 / 1_000_000, + "cache_read_input_token_cost_above_512k_tokens": 0.12 / 1_000_000, + "cache_read_input_token_cost_priority": 0.09 / 1_000_000, + "cache_read_input_token_cost_above_512k_tokens_priority": 0.18 / 1_000_000, + "max_input_tokens": 1_000_000, + "max_output_tokens": 524_288, + "mode": "chat", + "source": "https://platform.minimax.io/docs/guides/pricing-paygo", + "supported_modalities": ["text", "image", "video"], + "supports_adaptive_thinking": True, + "supports_function_calling": True, + "supports_multimodal": True, + "supports_prompt_caching": True, + "supports_reasoning": True, + "supports_system_messages": True, + "supports_tool_choice": True, + "supports_video_input": True, + "supports_vision": True, +} + +_MINIMAX_M27_COSTS: ModelCosts = { + "input_cost_per_token": 0.3 / 1_000_000, + "output_cost_per_token": 1.2 / 1_000_000, + "cache_read_input_token_cost": 0.06 / 1_000_000, + "cache_creation_input_token_cost": 0.375 / 1_000_000, + "max_input_tokens": 204_800, + "max_output_tokens": 204_800, + "mode": "chat", + "source": "https://platform.minimax.io/docs/guides/pricing-paygo", + "supported_modalities": ["text"], + "supports_function_calling": True, + "supports_prompt_caching": True, + "supports_reasoning": True, + "supports_system_messages": True, + "supports_tool_choice": True, +} + +ADDITIONAL_MODEL_COSTS: dict[str, ModelCosts] = { + "minimax/MiniMax-M3": cast( + "ModelCosts", {**_MINIMAX_M3_COSTS, "litellm_provider": "minimax"} + ), + "anthropic/MiniMax-M3": cast( + "ModelCosts", {**_MINIMAX_M3_COSTS, "litellm_provider": "anthropic"} + ), + "minimax/MiniMax-M2.7": cast( + "ModelCosts", {**_MINIMAX_M27_COSTS, "litellm_provider": "minimax"} + ), + "anthropic/MiniMax-M2.7": cast( + "ModelCosts", {**_MINIMAX_M27_COSTS, "litellm_provider": "anthropic"} + ), +} + + +def _select_token_cost( + costs: ModelCosts, + key: str, + *, + prompt_tokens: int, + service_tier: str | None, +) -> float: + """Select a token cost using the request size and service tier.""" + cost_map: dict[str, Any] = dict(costs) + selected_key = key + + threshold_key = f"{key}_above_512k_tokens" + if prompt_tokens > 512_000 and threshold_key in cost_map: + selected_key = threshold_key + + priority_key = f"{selected_key}_priority" + if ( + service_tier is not None + and service_tier.lower() == "priority" + and priority_key in cost_map + ): + selected_key = priority_key + + return float(cost_map.get(selected_key, cost_map.get(key, 0.0))) class ModelCostRegistry: @@ -30,7 +144,8 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: def __init__(self): if not hasattr(self, "_initialized"): - self._model_costs = model_cost + self._model_costs = cast("dict[str, ModelCosts]", model_cost) + self._model_costs.update(ADDITIONAL_MODEL_COSTS) self._initialized = True def register_model_costs(self, model: str, costs: ModelCosts) -> None: @@ -61,5 +176,73 @@ def get_model_costs(self, model: str) -> ModelCosts | None: """ return self._model_costs.get(model) + def calculate_costs( + self, + model: str, + *, + prompt_tokens: int, + completion_tokens: int, + cache_read_input_tokens: int = 0, + cache_creation_input_tokens: int = 0, + service_tier: str | None = None, + ) -> tuple[float, float] | None: + """Calculate input and output costs for one model response.""" + costs = self.get_model_costs(model) + if costs is None: + return None + + prompt_tokens = max(prompt_tokens, 0) + completion_tokens = max(completion_tokens, 0) + if model not in ADDITIONAL_MODEL_COSTS: + return ( + prompt_tokens * costs["input_cost_per_token"], + completion_tokens * costs["output_cost_per_token"], + ) + + cache_read_input_tokens = min(max(cache_read_input_tokens, 0), prompt_tokens) + remaining_tokens = prompt_tokens - cache_read_input_tokens + cache_creation_input_tokens = min( + max(cache_creation_input_tokens, 0), remaining_tokens + ) + uncached_input_tokens = remaining_tokens - cache_creation_input_tokens + + input_rate = _select_token_cost( + costs, + "input_cost_per_token", + prompt_tokens=prompt_tokens, + service_tier=service_tier, + ) + output_rate = _select_token_cost( + costs, + "output_cost_per_token", + prompt_tokens=prompt_tokens, + service_tier=service_tier, + ) + cache_read_rate = _select_token_cost( + costs, + "cache_read_input_token_cost", + prompt_tokens=prompt_tokens, + service_tier=service_tier, + ) + cache_creation_rate = _select_token_cost( + costs, + "cache_creation_input_token_cost", + prompt_tokens=prompt_tokens, + service_tier=service_tier, + ) + + if "cache_read_input_token_cost" not in costs: + cache_read_rate = input_rate + if "cache_creation_input_token_cost" not in costs: + cache_creation_rate = input_rate + + input_cost = ( + uncached_input_tokens * input_rate + + cache_read_input_tokens * cache_read_rate + + cache_creation_input_tokens * cache_creation_rate + ) + output_cost = completion_tokens * output_rate + return input_cost, output_cost + model_cost_registry = ModelCostRegistry() diff --git a/tests/unit/model_cost_registry/__init__.py b/tests/unit/model_cost_registry/__init__.py new file mode 100644 index 0000000000..8521feb3bb --- /dev/null +++ b/tests/unit/model_cost_registry/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2026 Microsoft Corporation. +# Licensed under the MIT License + +"""Model cost registry tests.""" diff --git a/tests/unit/model_cost_registry/test_model_cost_registry.py b/tests/unit/model_cost_registry/test_model_cost_registry.py new file mode 100644 index 0000000000..5c35f57061 --- /dev/null +++ b/tests/unit/model_cost_registry/test_model_cost_registry.py @@ -0,0 +1,124 @@ +# Copyright (c) 2026 Microsoft Corporation. +# Licensed under the MIT License + +"""Tests for the model cost registry.""" + +import pytest +from graphrag_llm.config import ModelConfig +from graphrag_llm.metrics.default_metrics_processor import DefaultMetricsProcessor +from graphrag_llm.model_cost_registry import model_cost_registry +from graphrag_llm.types import LLMCompletionResponse + + +@pytest.mark.parametrize("provider", ["minimax", "anthropic"]) +@pytest.mark.parametrize( + ("model", "max_input_tokens", "modalities", "adaptive_thinking"), + [ + ("MiniMax-M3", 1_000_000, ["text", "image", "video"], True), + ("MiniMax-M2.7", 204_800, ["text"], False), + ], +) +def test_minimax_model_costs_registered_for_supported_protocols( + provider: str, + model: str, + max_input_tokens: int, + modalities: list[str], + adaptive_thinking: bool, +) -> None: + """Ensure each model and protocol alias resolves to the target metadata.""" + costs = model_cost_registry.get_model_costs(f"{provider}/{model}") + + assert costs is not None + assert costs.get("max_input_tokens") == max_input_tokens + assert costs["input_cost_per_token"] == 0.3 / 1_000_000 + assert costs["output_cost_per_token"] == 1.2 / 1_000_000 + assert costs.get("supported_modalities") == modalities + assert costs.get("supports_adaptive_thinking", False) is adaptive_thinking + assert costs.get("supports_reasoning") is True + + +@pytest.mark.parametrize( + ("prompt_tokens", "service_tier", "expected_input", "expected_output"), + [ + (512_000, None, 0.1296, 0.0012), + (600_000, None, 0.312, 0.0024), + (512_000, "priority", 0.1944, 0.0018), + (600_000, "priority", 0.468, 0.0036), + ], +) +def test_minimax_m3_pricing_tiers( + prompt_tokens: int, + service_tier: str | None, + expected_input: float, + expected_output: float, +) -> None: + """Apply each context-length and service-tier rate, including cache reads.""" + costs = model_cost_registry.calculate_costs( + "minimax/MiniMax-M3", + prompt_tokens=prompt_tokens, + completion_tokens=1_000, + cache_read_input_tokens=100_000, + service_tier=service_tier, + ) + + assert costs is not None + input_cost, output_cost = costs + assert input_cost == pytest.approx(expected_input) + assert output_cost == pytest.approx(expected_output) + + +def test_minimax_m27_cache_read_and_creation_costs() -> None: + """Apply the separate cache-read and cache-creation rates for M2.7.""" + costs = model_cost_registry.calculate_costs( + "minimax/MiniMax-M2.7", + prompt_tokens=1_000, + completion_tokens=100, + cache_read_input_tokens=200, + cache_creation_input_tokens=300, + ) + + assert costs is not None + input_cost, output_cost = costs + assert input_cost == pytest.approx(0.0002745) + assert output_cost == pytest.approx(0.00012) + + +def test_default_metrics_processor_uses_service_tier_and_cached_tokens() -> None: + """Calculate response metrics from actual usage details and service tier.""" + response = LLMCompletionResponse.model_validate({ + "id": "response-id", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": None, + "message": {"content": "Done.", "role": "assistant"}, + } + ], + "created": 0, + "model": "MiniMax-M3", + "object": "chat.completion", + "service_tier": "priority", + "usage": { + "completion_tokens": 1_000, + "prompt_tokens": 600_000, + "total_tokens": 601_000, + "prompt_tokens_details": {"cached_tokens": 100_000}, + }, + }) + metrics: dict[str, float] = {} + + DefaultMetricsProcessor().process_metrics( + model_config=ModelConfig( + model_provider="minimax", + model="MiniMax-M3", + api_key="test-key", + ), + metrics=metrics, + input_args={}, + response=response, + ) + + assert metrics["input_cost"] == pytest.approx(0.468) + assert metrics["output_cost"] == pytest.approx(0.0036) + assert metrics["total_cost"] == pytest.approx(0.4716)