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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,17 @@ GOOGLE_API_KEY=
GOOGLE_GENAI_USE_VERTEXAI=false
#GOOGLE_CLOUD_PROJECT=
MINIMAX_PLAN_API_KEY=
# Use https://api.minimaxi.com/anthropic in China.
MINIMAX_PLAN_BASE_URL=https://api.minimax.io/anthropic
MIMO_PLAN_API_KEY=

# OpenAI Compatible Provider
# MiniMax example: use https://api.minimax.io/v1 globally or
# https://api.minimaxi.com/v1 in China, with MiniMax-M3 or MiniMax-M2.7.
# OPENAI_COMPATIBLE_PROVIDER=MiniMax
# OPENAI_COMPATIBLE_BASE_URL=https://api.minimax.io/v1
# OPENAI_COMPATIBLE_MODEL=MiniMax-M3
# OPENAI_COMPATIBLE_MODEL_LITE=MiniMax-M2.7
OPENAI_COMPATIBLE_API_KEY=
OPENAI_COMPATIBLE_PROVIDER=
OPENAI_COMPATIBLE_BASE_URL=
Expand Down
3 changes: 3 additions & 0 deletions intentkit/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ def __init__(self) -> None:
self.deepseek_api_key: str | None = self.load("DEEPSEEK_API_KEY")
self.xai_api_key: str | None = self.load("XAI_API_KEY")
self.minimax_plan_api_key: str | None = self.load("MINIMAX_PLAN_API_KEY")
self.minimax_plan_base_url: str = self.load(
"MINIMAX_PLAN_BASE_URL", "https://api.minimax.io/anthropic"
)
self.mimo_plan_api_key: str | None = self.load("MIMO_PLAN_API_KEY")
self.openrouter_api_key: str | None = self.load("OPENROUTER_API_KEY")
# OpenAI Compatible provider
Expand Down
93 changes: 72 additions & 21 deletions intentkit/models/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,26 @@ def display_name(self) -> str:
return display_names.get(self, self.value)


class LLMPricingTier(BaseModel):
"""Token pricing for one service tier and input-length range."""

service_tier: str = "standard"
input_tokens_lte: int | None = Field(default=None, ge=0)
input_tokens_gt: int | None = Field(default=None, ge=0)
input_price: Decimal
cached_input_price: Decimal | None = None
cache_write_price: Decimal | None = None
output_price: Decimal

def matches(self, input_tokens: int, service_tier: str) -> bool:
"""Return whether this tier applies to the request."""
if self.service_tier != service_tier:
return False
if self.input_tokens_lte is not None and input_tokens > self.input_tokens_lte:
return False
return self.input_tokens_gt is None or input_tokens > self.input_tokens_gt


class LLMModelInfo(BaseModel):
"""Information about an LLM model."""

Expand All @@ -229,7 +249,9 @@ class LLMModelInfo(BaseModel):
enabled: bool = Field(default=True)
input_price: Decimal # Price per 1M input tokens in USD
cached_input_price: Decimal | None = None # Price per 1M cached input tokens in USD
cache_write_price: Decimal | None = None # Price per 1M cache-write tokens in USD
output_price: Decimal # Price per 1M output tokens in USD
pricing_tiers: list[LLMPricingTier] = Field(default_factory=list)
price_level: int | None = Field(
default=None, ge=1, le=5
) # Price level rating from 1-5
Expand All @@ -244,6 +266,8 @@ class LLMModelInfo(BaseModel):
reasoning_effort: str | None = (
None # Reasoning effort level: "xhigh", "high", "medium", "low", "minimal", "none", or None
)
thinking_modes: list[str] = Field(default_factory=list)
default_thinking_mode: str | None = None
supports_temperature: bool = (
True # Whether the model supports temperature parameter
)
Expand Down Expand Up @@ -330,30 +354,51 @@ async def get_all(cls) -> list["LLMModelInfo"]:
"""
return list(AVAILABLE_MODELS.values())

def prices_for(
self, input_tokens: int, service_tier: str = "standard"
) -> tuple[Decimal, Decimal | None, Decimal | None, Decimal]:
"""Return prices for the matching service tier and input range."""
for tier in self.pricing_tiers:
if tier.matches(input_tokens, service_tier):
return (
tier.input_price,
tier.cached_input_price,
tier.cache_write_price,
tier.output_price,
)
return (
self.input_price,
self.cached_input_price,
self.cache_write_price,
self.output_price,
)

async def calculate_cost(
self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0
self,
input_tokens: int,
output_tokens: int,
cached_input_tokens: int = 0,
service_tier: str = "standard",
) -> Decimal:
"""Calculate the cost for a given number of tokens."""
global credit_per_usdc
if not credit_per_usdc:
credit_per_usdc = (await AppSetting.payment()).credit_per_usdc

input_price, cached_input_price, _, output_price = self.prices_for(
input_tokens, service_tier
)
# Determine effective price for cached input tokens
effective_cached_price = (
self.cached_input_price
if self.cached_input_price is not None
else self.input_price
cached_input_price if cached_input_price is not None else input_price
)
# Clamp cached to total input (defensive against provider inconsistencies)
effective_cached = min(cached_input_tokens, input_tokens)
# Non-cached input tokens = total input - cached
non_cached_input = input_tokens - effective_cached

input_cost = (
credit_per_usdc
* Decimal(non_cached_input)
* self.input_price
/ Decimal(1000000)
credit_per_usdc * Decimal(non_cached_input) * input_price / Decimal(1000000)
).quantize(FOURPLACES, rounding=ROUND_HALF_UP)
cached_input_cost = (
credit_per_usdc
Expand All @@ -362,17 +407,18 @@ async def calculate_cost(
/ Decimal(1000000)
).quantize(FOURPLACES, rounding=ROUND_HALF_UP)
output_cost = (
credit_per_usdc
* Decimal(output_tokens)
* self.output_price
/ Decimal(1000000)
credit_per_usdc * Decimal(output_tokens) * output_price / Decimal(1000000)
).quantize(FOURPLACES, rounding=ROUND_HALF_UP)
return (input_cost + cached_input_cost + output_cost).quantize(
FOURPLACES, rounding=ROUND_HALF_UP
)

def cost_usd(
self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0
self,
input_tokens: int,
output_tokens: int,
cached_input_tokens: int = 0,
service_tier: str = "standard",
) -> Decimal:
"""USD cost for a generation's token usage.

Expand All @@ -384,15 +430,16 @@ def cost_usd(
output_tokens = max(output_tokens, 0)
cached = min(max(cached_input_tokens, 0), input_tokens)
non_cached = input_tokens - cached
input_price, cached_input_price, _, output_price = self.prices_for(
input_tokens, service_tier
)
cached_price = (
self.cached_input_price
if self.cached_input_price is not None
else self.input_price
cached_input_price if cached_input_price is not None else input_price
)
return (
Decimal(non_cached) * self.input_price
Decimal(non_cached) * input_price
+ Decimal(cached) * cached_price
+ Decimal(output_tokens) * self.output_price
+ Decimal(output_tokens) * output_price
) / Decimal(1000000)


Expand Down Expand Up @@ -467,12 +514,16 @@ async def get_token_limit(self) -> int:
return info.context_length

async def calculate_cost(
self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0
self,
input_tokens: int,
output_tokens: int,
cached_input_tokens: int = 0,
service_tier: str = "standard",
) -> Decimal:
"""Calculate the cost for a given number of tokens."""
info = await self.model_info()
return await info.calculate_cost(
input_tokens, output_tokens, cached_input_tokens
input_tokens, output_tokens, cached_input_tokens, service_tier
)


Expand Down Expand Up @@ -888,7 +939,7 @@ async def create_instance(self, params: dict[str, Any] = {}) -> BaseChatModel:
kwargs: dict[str, Any] = {
"model": info.id,
"api_key": config.minimax_plan_api_key,
"base_url": "https://api.minimax.io/anthropic",
"base_url": config.minimax_plan_base_url,
"timeout": info.timeout,
"max_retries": 3,
}
Expand Down
70 changes: 63 additions & 7 deletions intentkit/models/llm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
#
# Field notes:
# prices USD per 1M tokens; quoted as strings to keep exact Decimal precision.
# pricing_tiers optional service-tier and input-length-specific prices.
# provider which integration serves the model (see LLMProvider).
# origin_provider OpenRouter-only. Pins the upstream provider on OpenRouter via
# {"order": [origin_provider], "allow_fallbacks": false}.
# Omit to let OpenRouter pick the upstream provider.
# reasoning_effort "xhigh"/"high"/"medium"/"low"/"minimal"/"none" or null.
# thinking_modes provider-supported thinking controls; default_thinking_mode is
# the default used by this catalog entry's adapter.
# intelligence/speed/price_level 1-5 ratings.


Expand Down Expand Up @@ -110,7 +113,7 @@
cached_input_price: "0.12"
output_price: "2.4"
price_level: 2
context_length: 524288
context_length: 1000000
output_length: 512000
intelligence: 5
speed: 3
Expand Down Expand Up @@ -885,11 +888,37 @@
name: "MiniMax M3"
provider: minimax
enabled: true
input_price: "0.6"
cached_input_price: "0.12"
output_price: "2.4"
input_price: "0.3"
cached_input_price: "0.06"
cache_write_price: null
output_price: "1.2"
pricing_tiers:
- service_tier: standard
input_tokens_lte: 512000
input_price: "0.3"
cached_input_price: "0.06"
cache_write_price: null
output_price: "1.2"
- service_tier: standard
input_tokens_gt: 512000
input_price: "0.6"
cached_input_price: "0.12"
cache_write_price: null
output_price: "2.4"
- service_tier: priority
input_tokens_lte: 512000
input_price: "0.45"
cached_input_price: "0.09"
cache_write_price: null
output_price: "1.8"
- service_tier: priority
input_tokens_gt: 512000
input_price: "0.9"
cached_input_price: "0.18"
cache_write_price: null
output_price: "3.6"
price_level: 2
context_length: 524288
context_length: 1000000
output_length: 512000
intelligence: 5
speed: 3
Expand All @@ -898,7 +927,34 @@
supports_video_input: true
supports_file_input: false
reasoning_effort: "high"
thinking_modes: ["adaptive", "disabled"]
default_thinking_mode: "disabled"
supports_temperature: true
supports_frequency_penalty: true
supports_presence_penalty: true
supports_frequency_penalty: false
supports_presence_penalty: false
timeout: 300

- id: "MiniMax-M2.7"
name: "MiniMax M2.7"
provider: minimax
enabled: true
input_price: "0.3"
cached_input_price: "0.06"
cache_write_price: "0.375"
output_price: "1.2"
price_level: 2
context_length: 204800
output_length: 204800
intelligence: 5
speed: 3
supports_image_input: false
supports_audio_input: false
supports_video_input: false
supports_file_input: false
reasoning_effort: "high"
thinking_modes: ["always_on"]
default_thinking_mode: "always_on"
supports_temperature: true
supports_frequency_penalty: false
supports_presence_penalty: false
timeout: 300
2 changes: 1 addition & 1 deletion tests/models/test_llm_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from intentkit.models.llm import LLMModelInfo, _resolve_generation_cost


def _info(input_price="0.5", cached="0.05", output="3"):
def _info(input_price: str = "0.5", cached: str | None = "0.05", output: str = "3"):
return LLMModelInfo.model_construct(
input_price=Decimal(input_price),
cached_input_price=Decimal(cached) if cached is not None else None,
Expand Down
Loading