fix(cache): opt M3 out of cache_control markers on Anthropic wire
MiniMax-M3 ships server-side automatic prefix caching on the
Anthropic-compatible endpoint (content-keyed, no marker needed —
see platform.minimax.io/docs/api-reference/text-prompt-caching).
cache_control markers are NOT on its explicit-cache support list
(which covers only M2.7/M2.5/M2.1/M2).
Emitting markers on M3:
- wasted serialization overhead
- risked perturbing the server-side prefix hash
- gave users a false sense of explicit-cache savings (the
cache_read_input_tokens field carries a +128 constant floor
and cache_creation_input_tokens is always 0 for M3)
Also add an opt-in debug=True parameter to normalize_usage() that
emits a debug-level log line carrying the observable cache fields.
This is the only reliable cache signal for M3 — off by default,
debug-level, scoped to the anthropic_messages wire, so production
callers see no impact.
Pin both changes with 8 new tests:
- 4 M3 tests covering provider, host, and custom-provider paths
- 1 regression guard ensuring M2.x caching is unaffected
- 3 observability tests (off-by-default, on-with-M3, on-with-Claude)
Verified end-to-end against api.minimaxi.com/anthropic/v1/messages
with MiniMax-M3[1m]: identical system prompt hit-rate with and
without markers; cache_read field is unreliable (128 floor),
input_tokens drop (8467 -> 1) is the real hit signal.
This commit is contained in:
parent
33855f1b30
commit
c1e2529ae2
|
|
@ -2215,13 +2215,26 @@ def anthropic_prompt_cache_policy(
|
|||
# api.minimax.io/anthropic / api.minimaxi.com/anthropic) get the
|
||||
# same cost reduction as Claude traffic.
|
||||
# Docs: https://platform.minimax.io/docs/api-reference/anthropic-api-compatible-cache
|
||||
#
|
||||
# MiniMax-M3 is intentionally excluded: M3 ships server-side automatic
|
||||
# prefix caching on this wire format (content-keyed, no marker needed —
|
||||
# see https://platform.minimax.io/docs/api-reference/text-prompt-caching),
|
||||
# and cache_control markers are NOT on its explicit-cache support list
|
||||
# (M2.7/M2.5/M2.1/M2 only). Emitting markers on M3 wasted serialization
|
||||
# overhead, risked perturbing the server-side prefix hash, and gave users
|
||||
# a false sense of explicit-cache savings. Empirically verified against
|
||||
# api.minimaxi.com/anthropic/v1/messages with MiniMax-M3[1m]: identical
|
||||
# system prompt hit-rate with and without markers; cache_read field has
|
||||
# a +128 floor and cache_creation is always 0, so the marker path is
|
||||
# neither observable nor billable for M3 users.
|
||||
if is_anthropic_wire:
|
||||
is_minimax_provider = provider_lower in {"minimax", "minimax-cn"}
|
||||
is_minimax_host = (
|
||||
base_url_host_matches(eff_base_url, "api.minimax.io")
|
||||
or base_url_host_matches(eff_base_url, "api.minimaxi.com")
|
||||
)
|
||||
if is_minimax_provider or is_minimax_host:
|
||||
is_minimax_m3 = "minimax-m3" in model_lower
|
||||
if (is_minimax_provider or is_minimax_host) and not is_minimax_m3:
|
||||
return True, True
|
||||
|
||||
# Qwen/Alibaba on OpenCode (Zen/Go) and native DashScope: OpenAI-wire
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -9,6 +10,8 @@ from typing import Any, Dict, Literal, Optional
|
|||
from agent.model_metadata import fetch_endpoint_model_metadata, fetch_model_metadata
|
||||
from utils import base_url_host_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
|
||||
|
||||
_ZERO = Decimal("0")
|
||||
|
|
@ -1207,6 +1210,7 @@ def normalize_usage(
|
|||
*,
|
||||
provider: Optional[str] = None,
|
||||
api_mode: Optional[str] = None,
|
||||
debug: bool = False,
|
||||
) -> CanonicalUsage:
|
||||
"""Normalize raw API response usage into canonical token buckets.
|
||||
|
||||
|
|
@ -1288,6 +1292,26 @@ def normalize_usage(
|
|||
getattr(completion_details, "reasoning_tokens", 0)
|
||||
)
|
||||
|
||||
# NOTE: opt-in cache observability for MiniMax-M3 (and similar providers
|
||||
# whose usage.cache_read_input_tokens carries a constant +128 floor and
|
||||
# whose usage.cache_creation_input_tokens is always 0). See
|
||||
# https://platform.minimax.io/docs/api-reference/text-prompt-caching
|
||||
# (Automatic Caching table). On M3, the cache_read field is NOT a
|
||||
# reliable hit signal; the only signal that survives is the input_tokens
|
||||
# drop between consecutive calls. This debug block logs the
|
||||
# observable-only fields so an operator can confirm cache is working
|
||||
# without relying on the misleading cache_read number.
|
||||
if debug and (mode == "anthropic_messages" or provider_name in {"minimax", "minimax-cn"}):
|
||||
logger.debug(
|
||||
"cache_observability provider=%s mode=%s input_tokens=%s "
|
||||
"output_tokens=%s cache_read_tokens=%s cache_write_tokens=%s "
|
||||
"(note: cache_read on this provider carries a +128 constant "
|
||||
"floor and is not a reliable hit signal — track input_tokens "
|
||||
"drops across calls instead)",
|
||||
provider_name, mode, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens,
|
||||
)
|
||||
|
||||
return CanonicalUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
|
|
|
|||
|
|
@ -312,3 +312,85 @@ def test_vertex_default_model_estimates_cached_usage(monkeypatch):
|
|||
|
||||
assert result.status == "estimated"
|
||||
assert result.amount_usd is not None and result.amount_usd > 0
|
||||
|
||||
|
||||
def test_normalize_usage_debug_off_emits_no_log(caplog):
|
||||
"""The opt-in cache observability block must NOT log when debug=False
|
||||
(the default). Production callers should never see this log line —
|
||||
it is only useful when an operator explicitly opts in to investigate
|
||||
cache behavior on MiniMax-M3 or any other provider whose
|
||||
cache_read_input_tokens carries a misleading constant offset.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
input_tokens=53,
|
||||
output_tokens=10,
|
||||
cache_read_input_tokens=128,
|
||||
cache_creation_input_tokens=0,
|
||||
)
|
||||
|
||||
with caplog.at_level("DEBUG", logger="agent.usage_pricing"):
|
||||
normalize_usage(usage, provider="minimax-cn", api_mode="anthropic_messages")
|
||||
|
||||
assert all("cache_observability" not in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_normalize_usage_debug_on_minimax_logs_cache_observability(caplog):
|
||||
"""When debug=True is passed and the provider is MiniMax/M3,
|
||||
normalize_usage emits a debug log line that records the observable-only
|
||||
fields (input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_write_tokens) so an operator can see real cache behavior
|
||||
without trusting the misleading cache_read number.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
input_tokens=1,
|
||||
output_tokens=11,
|
||||
cache_read_input_tokens=8594,
|
||||
cache_creation_input_tokens=0,
|
||||
)
|
||||
|
||||
with caplog.at_level("DEBUG", logger="agent.usage_pricing"):
|
||||
normalize_usage(
|
||||
usage,
|
||||
provider="minimax-cn",
|
||||
api_mode="anthropic_messages",
|
||||
debug=True,
|
||||
)
|
||||
|
||||
cache_obs_records = [r for r in caplog.records if "cache_observability" in r.message]
|
||||
assert len(cache_obs_records) == 1
|
||||
record = cache_obs_records[0]
|
||||
assert "input_tokens=1" in record.message
|
||||
assert "output_tokens=11" in record.message
|
||||
assert "cache_read_tokens=8594" in record.message
|
||||
assert "cache_write_tokens=0" in record.message
|
||||
assert "+128 constant floor" in record.message
|
||||
|
||||
|
||||
def test_normalize_usage_debug_on_claude_also_logs_cache_observability(caplog):
|
||||
"""The opt-in observability block fires for every anthropic_messages
|
||||
response — not just MiniMax — because the input_tokens-vs-cache_read
|
||||
framing is useful diagnostic information on any Anthropic-compatible
|
||||
wire (e.g. OpenRouter Claude, Bedrock Claude, GLM Claude) where a
|
||||
provider's cache_read_input_tokens semantics may differ from
|
||||
Anthropic's native contract. The block is debug-level and off by
|
||||
default, so emitting it for Claude traffic has zero production cost.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
input_tokens=100,
|
||||
output_tokens=20,
|
||||
cache_read_input_tokens=50,
|
||||
cache_creation_input_tokens=10,
|
||||
)
|
||||
|
||||
with caplog.at_level("DEBUG", logger="agent.usage_pricing"):
|
||||
normalize_usage(
|
||||
usage,
|
||||
provider="anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
debug=True,
|
||||
)
|
||||
|
||||
cache_obs_records = [r for r in caplog.records if "cache_observability" in r.message]
|
||||
assert len(cache_obs_records) == 1
|
||||
assert "input_tokens=100" in cache_obs_records[0].message
|
||||
assert "cache_read_tokens=50" in cache_obs_records[0].message
|
||||
|
|
|
|||
|
|
@ -211,6 +211,61 @@ class TestMiniMaxAnthropicWire:
|
|||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_minimax_m3_on_provider_minimax_does_not_cache(self):
|
||||
# MiniMax-M3 uses server-side automatic prefix caching on the
|
||||
# /anthropic wire (content-keyed, no marker needed). M3 is NOT on
|
||||
# MiniMax's explicit-cache support list (which covers only M2.7 /
|
||||
# M2.5 / M2.1 / M2), and emitting cache_control markers on M3 is
|
||||
# neither observable nor billable — it only wastes serialization
|
||||
# overhead and risks perturbing the server-side prefix hash. Marker
|
||||
# path must stay off for M3 so the response.usage fields reflect
|
||||
# server-side automatic caching without interference.
|
||||
agent = _make_agent(
|
||||
provider="minimax",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="MiniMax-M3[1m]",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_minimax_m3_on_china_endpoint_does_not_cache(self):
|
||||
# Mirror of the above against the China-region host. The
|
||||
# M3-vs-M2 substring guard must trigger on the model name
|
||||
# regardless of which MiniMax host the user picks.
|
||||
agent = _make_agent(
|
||||
provider="minimax-cn",
|
||||
base_url="https://api.minimaxi.com/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_minimax_m3_via_custom_provider_does_not_cache(self):
|
||||
# When the user wires a custom provider manually at MiniMax's
|
||||
# Anthropic URL with M3, host-match alone must NOT bypass the
|
||||
# M3-specific opt-out.
|
||||
agent = _make_agent(
|
||||
provider="custom",
|
||||
base_url="https://api.minimaxi.com/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="MiniMax-M3[1m]",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_minimax_m27_still_caches_after_m3_opt_out(self):
|
||||
# Regression guard: the M3 substring check must not collide with
|
||||
# M2.7 / M2.5 / M2.1 / M2 model names. "minimax-m3" is not a
|
||||
# substring of "minimax-m2.7" etc., but pin this with a test so a
|
||||
# future "startswith minimax-m" loosening can't silently drop the
|
||||
# M2.x cache_control path.
|
||||
agent = _make_agent(
|
||||
provider="minimax",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="MiniMax-M2.7",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, True)
|
||||
|
||||
|
||||
class TestOpenAIWireFormatOnCustomProvider:
|
||||
"""A custom provider using chat_completions (OpenAI wire) should NOT get caching."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue