From 43ae1e5f2c808a28d55815a5da877afd9acaa6bb Mon Sep 17 00:00:00 2001 From: LaoYueHanNi Date: Wed, 3 Jun 2026 14:28:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(coding-plan):=20=E9=80=82=E9=85=8D=20MiniMa?= =?UTF-8?q?x=20=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2=E6=96=B0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E9=BB=98=E8=AE=A4=E5=AE=9A=E4=BB=B7=20(#3518)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- src-tauri/src/database/schema.rs | 1 + src-tauri/src/services/coding_plan.rs | 280 +++++++++++++++++++++----- 2 files changed, 236 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 60cf7ca86..ce0f87087 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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"), diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 8d22e10e9..e872e7fde 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -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 { + 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); + } }