fix(coding-plan): 适配 MiniMax 余额查询新接口 + 默认定价 (#3518)

* fix(coding-plan): 适配 MiniMax 余额查询新接口 + 默认定价

## 余额查询修复

/v1/api/openplatform/coding_plan/remains 新接口不再返回
current_*_total_count / current_*_usage_count(恒为 0),
改为返回 current_*_remaining_percent(剩余百分比)。
旧代码会因守卫失败导致 tiers 为空,tray 不再显示用量。

- 切换数据源到 *_remaining_percent,反转为已用百分比
- 过滤 model_name == "general",跳过 video 等非编程模型
- 提取 parse_minimax_tiers 纯函数,便于无 mock 单元测试
- 复用 TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT 常量
- 新增 6 个单元测试覆盖主路径与边界

## 默认定价新增
按 2026-06-01 上线的人民币价格 (CNY/7.0 汇率,口径与 M2.7 反推一致)
换算为 USD,在 seed_model_pricing 数组中追加 minimax-m3:
| model_id     | display_name | input | output | cache_read | cache_creation |
|--------------|--------------|-------|--------|------------|----------------|
| minimax-m3   | MiniMax M3   | 0.60  | 2.40   | 0.12       | 0              |
不取512K上下文定价(参照当前模型标准)

* fix(coding-plan): MiniMax 兼容无周限额套餐 (current_weekly_status=3)
This commit is contained in:
LaoYueHanNi
2026-06-03 14:28:05 +08:00
committed by GitHub
parent b4f262c7bd
commit 43ae1e5f2c
2 changed files with 236 additions and 45 deletions
+1
View File
@@ -1744,6 +1744,7 @@ impl Database {
"0.06",
"0.375",
),
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
// GLM (智谱)
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
+235 -45
View File
@@ -370,50 +370,8 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
}
}
let mut tiers = Vec::new();
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
// 只取第一个模型(MiniMax-M*,主力编程模型)
if let Some(item) = model_remains.first() {
// usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比
let interval_total = item
.get("current_interval_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let interval_remaining = item
.get("current_interval_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let end_time = item.get("end_time").and_then(|v| v.as_i64());
if interval_total > 0.0 {
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization: ((interval_total - interval_remaining) / interval_total) * 100.0,
resets_at: end_time.and_then(millis_to_iso8601),
});
}
// 周额度
let weekly_total = item
.get("current_weekly_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_remaining = item
.get("current_weekly_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
if weekly_total > 0.0 {
tiers.push(QuotaTier {
name: "weekly_limit".to_string(),
utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0,
resets_at: weekly_end.and_then(millis_to_iso8601),
});
}
}
}
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
let tiers = parse_minimax_tiers(&body);
SubscriptionQuota {
tool: "coding_plan".to_string(),
@@ -427,6 +385,68 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
}
}
/// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。
///
/// 新接口语义:`current_*_remaining_percent` 是"剩余百分比"(0-100),
/// `model_remains` 数组里有 `general`(编程套餐)和 `video` 等其他模型,
/// 这里只取 `general`,跳过 video。
///
/// 5h 桶始终存在;周桶并非所有套餐都有,靠 `current_weekly_status == 1`
/// 判定激活(无周限额套餐该字段为 3,`remaining_percent` 恒为 100,不应展示)。
fn parse_minimax_tiers(body: &serde_json::Value) -> Vec<QuotaTier> {
let mut tiers = Vec::new();
let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else {
return tiers;
};
// 只取 model_name == "general" 的条目,跳过 video 等非编程模型
let Some(item) = model_remains.iter().find(|item| {
item.get("model_name")
.and_then(|v| v.as_str())
.map(|s| s == "general")
.unwrap_or(false)
}) else {
return tiers;
};
// 5h 桶:剩余百分比 → 已用百分比
if let Some(remain_pct) = item
.get("current_interval_remaining_percent")
.and_then(|v| v.as_f64())
{
let resets_at = item
.get("end_time")
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
tiers.push(QuotaTier {
name: TIER_FIVE_HOUR.to_string(),
utilization: 100.0 - remain_pct,
resets_at,
});
}
// 周桶:仅当 status=1 时激活;status=3 等表示该套餐无周限额,跳过
if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) {
if let Some(remain_pct) = item
.get("current_weekly_remaining_percent")
.and_then(|v| v.as_f64())
{
let resets_at = item
.get("weekly_end_time")
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
tiers.push(QuotaTier {
name: TIER_WEEKLY_LIMIT.to_string(),
utilization: 100.0 - remain_pct,
resets_at,
});
}
}
tiers
}
// ── 公开入口 ────────────────────────────────────────────────
pub async fn get_coding_plan_quota(
@@ -474,7 +494,7 @@ pub async fn get_coding_plan_quota(
#[cfg(test)]
mod tests {
use super::{parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
use super::{parse_minimax_tiers, parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
use serde_json::json;
#[test]
@@ -604,4 +624,174 @@ mod tests {
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
}
// ── MiniMax ──
#[test]
fn minimax_general_two_tiers_from_remaining_percent() {
// 主路径:general 桶 5h 剩 98% / weekly 剩 95% → 已用 2% / 5%
let body = json!({
"model_remains": [
{
"model_name": "general",
"current_interval_remaining_percent": 98.0,
"current_weekly_remaining_percent": 95.0,
"current_interval_status": 1,
"current_weekly_status": 1,
"end_time": 1_780_329_600_000_i64,
"weekly_end_time": 1_780_848_000_000_i64
},
{
"model_name": "video",
"current_interval_remaining_percent": 100.0,
"current_weekly_remaining_percent": 100.0
}
],
"base_resp": { "status_code": 0, "status_msg": "success" }
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 2.0);
assert!(tiers[0].resets_at.is_some());
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 5.0);
assert!(tiers[1].resets_at.is_some());
}
#[test]
fn minimax_skips_video_and_finds_general_in_any_position() {
// 防御性:即使 video 排在数组前面,general 排在后面,仍应被定位到。
let body = json!({
"model_remains": [
{
"model_name": "video",
"current_interval_remaining_percent": 50.0,
"current_weekly_remaining_percent": 50.0
},
{
"model_name": "general",
"current_interval_remaining_percent": 80.0,
"current_weekly_remaining_percent": 70.0,
"current_interval_status": 1,
"current_weekly_status": 1
}
]
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 2);
// 取的是 general 桶,不是 video(20%/30% 而非 50%/50%)
assert_eq!(tiers[0].utilization, 20.0);
assert_eq!(tiers[1].utilization, 30.0);
}
#[test]
fn minimax_missing_general_returns_empty() {
// model_remains 只有 video / 空 / 缺字段 → 不应崩溃,tiers 为空
let body = json!({
"model_remains": [
{
"model_name": "video",
"current_interval_remaining_percent": 100.0,
"current_weekly_remaining_percent": 100.0
}
]
});
assert!(parse_minimax_tiers(&body).is_empty());
let body_empty: serde_json::Value = json!({ "model_remains": [] });
assert!(parse_minimax_tiers(&body_empty).is_empty());
let body_no_field = json!({});
assert!(parse_minimax_tiers(&body_no_field).is_empty());
}
#[test]
fn minimax_missing_percent_fields_skips_tier() {
// 字段缺失时只跳过对应桶,另一边仍能展示
let body = json!({
"model_remains": [{
"model_name": "general",
"current_interval_remaining_percent": 60.0,
"current_weekly_status": 1
// 缺 current_weekly_remaining_percent
}]
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 40.0);
}
#[test]
fn minimax_negative_percent_passes_through() {
// 防御性:与 parse_zhipu_token_tiers 约定一致,负数 / 超 100 不做范围裁剪
let body = json!({
"model_remains": [{
"model_name": "general",
"current_interval_remaining_percent": -5.0,
"current_weekly_remaining_percent": 150.0,
"current_interval_status": 1,
"current_weekly_status": 1
}]
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].utilization, 105.0); // 100 - (-5)
assert_eq!(tiers[1].utilization, -50.0); // 100 - 150
}
#[test]
fn minimax_weekly_status_3_skips_weekly_tier() {
// 无周限额套餐:current_weekly_status=3,remaining_percent 恒为 100,
// 不应推 weekly_limit tier(否则会显示"0% 已用"的假周桶)
let body = json!({
"model_remains": [
{
"model_name": "general",
"start_time": 1_780_347_600_000_i64,
"end_time": 1_780_365_600_000_i64,
"remains_time": 4_161_372_i64,
"current_interval_remaining_percent": 99,
"current_interval_status": 1,
"current_weekly_total_count": 0,
"current_weekly_usage_count": 0,
"weekly_start_time": 1_780_243_200_000_i64,
"weekly_end_time": 1_780_848_000_000_i64,
"weekly_remains_time": 486_561_372_i64,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 100
},
{
"model_name": "video",
"current_interval_remaining_percent": 100,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 100
}
],
"base_resp": { "status_code": 0, "status_msg": "success" }
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 1.0);
assert!(tiers[0].resets_at.is_some());
}
#[test]
fn minimax_weekly_status_2_also_skips_weekly_tier() {
// 防御性:除 1 之外的 status 都视为周桶未激活,跳过
let body = json!({
"model_remains": [{
"model_name": "general",
"current_interval_remaining_percent": 80.0,
"current_weekly_remaining_percent": 50.0,
"current_weekly_status": 2
}]
});
let tiers = parse_minimax_tiers(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 20.0);
}
}