From 25d23e0fa170f814de46322e30ec6ff3f4ab942f Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 23 Mar 2026 13:54:02 +0000 Subject: [PATCH 01/16] Update MiniMax plugin to raw model-calls display with CN resource buckets --- README.md | 2 +- docs/providers/minimax.md | 37 ++++- plugins/minimax/plugin.js | 295 ++++++++++++++++++++++----------- plugins/minimax/plugin.json | 6 +- plugins/minimax/plugin.test.js | 249 +++++++++++++++++++++++++--- 5 files changed, 460 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 84c27e21..dfc24df0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining - [**Kimi Code**](docs/providers/kimi.md) / session, weekly -- [**MiniMax**](docs/providers/minimax.md) / coding plan session +- [**MiniMax**](docs/providers/minimax.md) / coding plan session model-calls, CN TTS/image buckets - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 9b78f338..d5f49cd1 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -8,6 +8,9 @@ - **Endpoint:** `GET https://api.minimax.io/v1/api/openplatform/coding_plan/remains` - **Auth:** `Authorization: Bearer ` - **Window model:** dynamic rolling 5-hour limit (per MiniMax Coding Plan docs) +- **Display note:** OpenUsage shows the raw text-session counts from the remains API as `model-calls`, because that matches the observed official usage display. +- **Docs note:** as of 2026-03-23, MiniMax public pricing/FAQ pages still describe Coding Plan in `prompts`, so this provider doc explains the mismatch explicitly. +- **CN note:** current CN docs use `https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains`. ## Authentication @@ -44,6 +47,7 @@ Fallbacks: When the selected region is `CN`, requests use: +- `https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains` - `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` - `https://api.minimaxi.com/v1/coding_plan/remains` @@ -62,24 +66,45 @@ Expected payload fields: ## Usage Mapping - Treat `current_interval_usage_count` as remaining prompts (MiniMax remains API behavior). +- For the main text `Session` line, OpenUsage displays the raw remains numbers as `model-calls` rather than converting them to `prompts`. - If only remaining aliases are provided, compute `used = total - remaining`. - If explicit used-count fields are provided, prefer them. -- Plan name is taken from explicit plan/title fields when available. -- If plan fields are missing in GLOBAL mode, infer plan tier from known limits (`100/300/1000/2000` prompts or `1500/4500/15000/30000` model-call equivalents). -- If plan fields are missing in CN mode, infer only exact known CN limits (`600/1500/4500` model-call counts). +- Plan name is taken from explicit plan/title fields when available, and normalized to a shared six-plan naming scheme: + - `Starter` + - `Plus` + - `Max` + - `Plus-High-Speed` + - `Max-High-Speed` + - `Ultra-High-Speed` +- If plan fields are missing in GLOBAL mode, infer only unambiguous plan tiers from known limits: + - `100` prompts or `1500` raw model-calls => `Starter` + - `2000` prompts or `30000` raw model-calls => `Ultra-High-Speed` +- Do not infer a GLOBAL plan from ambiguous limits (`300/1000` prompts or `4500/15000` raw model-calls), because current public docs expose both Standard and High-Speed plans for those quotas. +- In CN mode, infer only unambiguous raw model-call tiers from the CN subscription table: + - `600` => `Starter` + - `30000` => `Ultra-High-Speed` +- Do not infer a CN plan from ambiguous limits (`1500/4500` raw model-calls), because CN standard and CN High-Speed plans overlap on those quotas. +- In CN mode, additional `model_remains[]` entries may appear as separate daily resource buckets, for example `Text to Speech HD` or `image-01`. - Use `end_time` for reset timestamp when present. - Fallback to `remains_time` when `end_time` is absent. - Use `start_time` + `end_time` as `periodDurationMs` when both are valid. +- Historical note: MiniMax public docs and pricing copy still describe Coding Plan in `prompts`, but the plugin follows the raw remains reading and labels the main text session as `model-calls`. +- Official package tables used for this split, checked on 2026-03-23: + - Global: + - CN: ## Output - **Plan**: best-effort from API payload (normalized to concise label, with ` (CN)` or ` (GLOBAL)` suffix) - **Session** (overview progress line): - `label`: `Session` - - `format`: count (`prompts`) - - `used`: computed used prompts - - `limit`: total prompt limit for current window + - `format`: count (`model-calls`) + - `used`: computed used model-call count from raw remains data + - `limit`: raw session limit from the remains payload - `resetsAt`: derived from `end_time` or `remains_time` +- **CN extra resources** (detail progress lines when present): + - `Text to Speech HD` / `Text to Speech Turbo`: count (`chars`) + - `Image Generation` / `image-01`: count (`images`) ## Errors diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 2e10476c..a1800c1c 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -4,26 +4,34 @@ "https://api.minimax.io/v1/coding_plan/remains", "https://www.minimax.io/v1/api/openplatform/coding_plan/remains", ] - const CN_PRIMARY_USAGE_URL = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" - const CN_FALLBACK_USAGE_URLS = ["https://api.minimaxi.com/v1/coding_plan/remains"] + const CN_PRIMARY_USAGE_URL = "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains" + const CN_FALLBACK_USAGE_URLS = [ + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/coding_plan/remains", + ] const GLOBAL_API_KEY_ENV_VARS = ["MINIMAX_API_KEY", "MINIMAX_API_TOKEN"] const CN_API_KEY_ENV_VARS = ["MINIMAX_CN_API_KEY", "MINIMAX_API_KEY", "MINIMAX_API_TOKEN"] const CODING_PLAN_WINDOW_MS = 5 * 60 * 60 * 1000 const CODING_PLAN_WINDOW_TOLERANCE_MS = 10 * 60 * 1000 - // GLOBAL plan tiers (based on prompt limits) - const GLOBAL_PROMPT_LIMIT_TO_PLAN = { + const DAILY_WINDOW_MS = 24 * 60 * 60 * 1000 + // Unambiguous prompt-based tiers kept for compatibility with older docs/examples. + const UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN = { 100: "Starter", - 300: "Plus", - 1000: "Max", - 2000: "Ultra", + 2000: "Ultra-High-Speed", + } + // Raw model-call tiers inferred from the current Global six-package lineup. + // 4500 and 15000 are ambiguous between Standard and High-Speed variants. + const GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN = { + 1500: "Starter", + 30000: "Ultra-High-Speed", } - // CN plan tiers (based on model call counts = prompts × 15) - // Starter: 40 prompts = 600, Plus: 100 prompts = 1500, Max: 300 prompts = 4500 - const CN_PROMPT_LIMIT_TO_PLAN = { + // Raw model-call tiers inferred from the current CN six-package lineup. + // 1500 and 4500 are ambiguous between Standard and High-Speed variants. + const CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN = { 600: "Starter", - 1500: "Plus", - 4500: "Max", + 30000: "Ultra-High-Speed", } + const MODEL_CALLS_SUFFIX = "model-calls" const MODEL_CALLS_PER_PROMPT = 15 function readString(value) { @@ -54,9 +62,24 @@ if (!raw) return null const compact = raw.replace(/\s+/g, " ").trim() const withoutPrefix = compact.replace(/^minimax\s+coding\s+plan\b[:\-]?\s*/i, "").trim() - if (withoutPrefix) return withoutPrefix - if (/coding\s+plan/i.test(compact)) return "Coding Plan" - return compact + const base = withoutPrefix || compact + if (/coding\s+plan/i.test(compact) && !withoutPrefix) return "Coding Plan" + + const canonical = base + .replace(/\s*-\s*/g, "-") + .replace(/极速版/gi, "High-Speed") + .replace(/highspeed/gi, "High-Speed") + .replace(/high-speed/gi, "High-Speed") + .replace(/\s+/g, " ") + .trim() + + if (/^starter$/i.test(canonical)) return "Starter" + if (/^plus$/i.test(canonical)) return "Plus" + if (/^max$/i.test(canonical)) return "Max" + if (/^plus-?high-speed$/i.test(canonical)) return "Plus-High-Speed" + if (/^max-?high-speed$/i.test(canonical)) return "Max-High-Speed" + if (/^ultra-?high-speed$/i.test(canonical)) return "Ultra-High-Speed" + return canonical } function inferPlanNameFromLimit(totalCount, endpointSelection) { @@ -65,15 +88,78 @@ const normalized = Math.round(n) if (endpointSelection === "CN") { - // CN totals are model-call counts; only exact known CN tiers should infer. - return CN_PROMPT_LIMIT_TO_PLAN[normalized] || null + if (CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized]) { + return CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized] + } + return null + } else if (GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized]) { + return GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized] } - if (GLOBAL_PROMPT_LIMIT_TO_PLAN[normalized]) return GLOBAL_PROMPT_LIMIT_TO_PLAN[normalized] + if (UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[normalized]) { + return UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[normalized] + } if (normalized % MODEL_CALLS_PER_PROMPT !== 0) return null const inferredPromptLimit = normalized / MODEL_CALLS_PER_PROMPT - return GLOBAL_PROMPT_LIMIT_TO_PLAN[inferredPromptLimit] || null + return UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[inferredPromptLimit] || null + } + + function normalizeUsageName(value) { + const raw = readString(value) + if (!raw) return null + return raw.replace(/\s+/g, " ").trim() + } + + function classifyUsageEntry(item, endpointSelection, index) { + const rawName = normalizeUsageName( + pickFirstString([ + item.model_name, + item.modelName, + item.resource_name, + item.resourceName, + item.name, + ]) + ) + const name = rawName ? rawName.toLowerCase() : "" + + if (endpointSelection !== "CN") { + return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } + } + + if ( + name.includes("text to speech hd") || + /^speech-[\d.]+-hd$/.test(name) + ) { + return { label: "Text to Speech HD", suffix: "chars", isSession: false } + } + if ( + name.includes("text to speech turbo") || + /^speech-[\d.]+-turbo$/.test(name) + ) { + return { label: "Text to Speech Turbo", suffix: "chars", isSession: false } + } + if (name.includes("image-01")) { + return { label: "image-01", suffix: "images", isSession: false } + } + if (name.includes("image generation")) { + return { label: "Image Generation", suffix: "images", isSession: false } + } + if ( + name.includes("minimax-m") || + name.includes("text model") || + name.includes("coding") + ) { + return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } + } + if (index === 0) { + return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } + } + return { + label: rawName || "Usage", + suffix: "count", + isSession: false, + } } function epochToMs(epoch) { @@ -212,6 +298,74 @@ throw "Could not parse usage data." } + function parseModelRemainEntry(ctx, item, endpointSelection, index) { + if (!item || typeof item !== "object") return null + + const usageMeta = classifyUsageEntry(item, endpointSelection, index) + let total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) + if (total === null || total <= 0) return null + + const usageFieldCount = readNumber(item.current_interval_usage_count ?? item.currentIntervalUsageCount) + const remainingCount = readNumber( + item.current_interval_remaining_count ?? + item.currentIntervalRemainingCount ?? + item.current_interval_remains_count ?? + item.currentIntervalRemainsCount ?? + item.current_interval_remain_count ?? + item.currentIntervalRemainCount ?? + item.remaining_count ?? + item.remainingCount ?? + item.remains_count ?? + item.remainsCount ?? + item.remaining ?? + item.remains ?? + item.left_count ?? + item.leftCount + ) + // MiniMax "coding_plan/remains" commonly returns remaining usage in current_interval_usage_count. + const inferredRemainingCount = remainingCount !== null ? remainingCount : usageFieldCount + const explicitUsed = readNumber( + item.current_interval_used_count ?? + item.currentIntervalUsedCount ?? + item.used_count ?? + item.used + ) + let used = explicitUsed + + if (used === null && inferredRemainingCount !== null) used = total - inferredRemainingCount + if (used === null) return null + + if (used < 0) used = 0 + if (used > total) used = total + + const startMs = epochToMs(item.start_time ?? item.startTime) + const endMs = epochToMs(item.end_time ?? item.endTime) + const remainsRaw = readNumber(item.remains_time ?? item.remainsTime) + const nowMs = Date.now() + const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs) + + let resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null + if (!resetsAt && remainsMs !== null) { + resetsAt = ctx.util.toIso(nowMs + remainsMs) + } + + let periodDurationMs = null + if (startMs !== null && endMs !== null && endMs > startMs) { + periodDurationMs = endMs - startMs + } else if (endpointSelection === "CN" && !usageMeta.isSession) { + periodDurationMs = DAILY_WINDOW_MS + } + + return { + label: usageMeta.label, + used, + total, + suffix: usageMeta.suffix, + resetsAt, + periodDurationMs, + } + } + function parsePayloadShape(ctx, payload, endpointSelection) { if (!payload || typeof payload !== "object") return null @@ -244,69 +398,18 @@ if (!modelRemains || modelRemains.length === 0) return null - let chosen = modelRemains[0] + const entries = [] + const seenLabels = Object.create(null) for (let i = 0; i < modelRemains.length; i += 1) { - const item = modelRemains[i] - if (!item || typeof item !== "object") continue - const total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) - if (total !== null && total > 0) { - chosen = item - break - } + const entry = parseModelRemainEntry(ctx, modelRemains[i], endpointSelection, i) + if (!entry) continue + if (seenLabels[entry.label]) continue + seenLabels[entry.label] = true + entries.push(entry) + if (endpointSelection !== "CN") break } - if (!chosen || typeof chosen !== "object") return null - - const total = readNumber(chosen.current_interval_total_count ?? chosen.currentIntervalTotalCount) - if (total === null || total <= 0) return null - - const usageFieldCount = readNumber(chosen.current_interval_usage_count ?? chosen.currentIntervalUsageCount) - const remainingCount = readNumber( - chosen.current_interval_remaining_count ?? - chosen.currentIntervalRemainingCount ?? - chosen.current_interval_remains_count ?? - chosen.currentIntervalRemainsCount ?? - chosen.current_interval_remain_count ?? - chosen.currentIntervalRemainCount ?? - chosen.remaining_count ?? - chosen.remainingCount ?? - chosen.remains_count ?? - chosen.remainsCount ?? - chosen.remaining ?? - chosen.remains ?? - chosen.left_count ?? - chosen.leftCount - ) - // MiniMax "coding_plan/remains" commonly returns remaining prompts in current_interval_usage_count. - const inferredRemainingCount = remainingCount !== null ? remainingCount : usageFieldCount - const explicitUsed = readNumber( - chosen.current_interval_used_count ?? - chosen.currentIntervalUsedCount ?? - chosen.used_count ?? - chosen.used - ) - let used = explicitUsed - - if (used === null && inferredRemainingCount !== null) used = total - inferredRemainingCount - if (used === null) return null - if (used < 0) used = 0 - if (used > total) used = total - - const startMs = epochToMs(chosen.start_time ?? chosen.startTime) - const endMs = epochToMs(chosen.end_time ?? chosen.endTime) - const remainsRaw = readNumber(chosen.remains_time ?? chosen.remainsTime) - const nowMs = Date.now() - const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs) - - let resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null - if (!resetsAt && remainsMs !== null) { - resetsAt = ctx.util.toIso(nowMs + remainsMs) - } - - let periodDurationMs = null - if (startMs !== null && endMs !== null && endMs > startMs) { - periodDurationMs = endMs - startMs - } + if (entries.length === 0) return null const explicitPlanName = normalizePlanName(pickFirstString([ data.current_subscribe_title, @@ -318,15 +421,13 @@ payload.plan_name, payload.plan, ])) - const inferredPlanName = inferPlanNameFromLimit(total, endpointSelection) + const sessionEntry = entries.find((entry) => entry.label === "Session") || entries[0] + const inferredPlanName = inferPlanNameFromLimit(sessionEntry.total, endpointSelection) const planName = explicitPlanName || inferredPlanName return { planName, - used, - total, - resetsAt, - periodDurationMs, + entries, } } @@ -362,21 +463,19 @@ throw "MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY." } - // CN API returns model call counts (needs division by 15 for prompts) - // GLOBAL API returns prompt counts directly - const isCnEndpoint = successfulEndpoint === "CN" - const displayMultiplier = isCnEndpoint ? 1 / MODEL_CALLS_PER_PROMPT : 1 - - const line = { - label: "Session", - used: Math.round(parsed.used * displayMultiplier), - limit: Math.round(parsed.total * displayMultiplier), - format: { kind: "count", suffix: "prompts" }, - } - if (parsed.resetsAt) line.resetsAt = parsed.resetsAt - if (parsed.periodDurationMs !== null) line.periodDurationMs = parsed.periodDurationMs + const lines = parsed.entries.map((entry) => { + const line = { + label: entry.label, + used: Math.round(entry.used), + limit: Math.round(entry.total), + format: { kind: "count", suffix: entry.suffix }, + } + if (entry.resetsAt) line.resetsAt = entry.resetsAt + if (entry.periodDurationMs !== null) line.periodDurationMs = entry.periodDurationMs + return ctx.line.progress(line) + }) - const result = { lines: [ctx.line.progress(line)] } + const result = { lines } if (parsed.planName) { const regionLabel = successfulEndpoint === "CN" ? " (CN)" : " (GLOBAL)" result.plan = parsed.planName + regionLabel diff --git a/plugins/minimax/plugin.json b/plugins/minimax/plugin.json index f8a714aa..5ff16e7e 100644 --- a/plugins/minimax/plugin.json +++ b/plugins/minimax/plugin.json @@ -7,6 +7,10 @@ "icon": "icon.svg", "brandColor": "#F5433C", "lines": [ - { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 } + { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Text to Speech HD", "scope": "detail" }, + { "type": "progress", "label": "Text to Speech Turbo", "scope": "detail" }, + { "type": "progress", "label": "Image Generation", "scope": "detail" }, + { "type": "progress", "label": "image-01", "scope": "detail" } ] } diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index fa1112a1..4979547b 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -4,8 +4,9 @@ import { makeCtx } from "../test-helpers.js" const PRIMARY_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" const FALLBACK_USAGE_URL = "https://api.minimax.io/v1/coding_plan/remains" const LEGACY_WWW_USAGE_URL = "https://www.minimax.io/v1/api/openplatform/coding_plan/remains" -const CN_PRIMARY_USAGE_URL = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" -const CN_FALLBACK_USAGE_URL = "https://api.minimaxi.com/v1/coding_plan/remains" +const CN_PRIMARY_USAGE_URL = "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains" +const CN_FALLBACK_USAGE_URL = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" +const CN_LEGACY_FALLBACK_USAGE_URL = "https://api.minimaxi.com/v1/coding_plan/remains" const loadPlugin = async () => { await import("./plugin.js") @@ -179,6 +180,7 @@ describe("minimax plugin", () => { status: 200, headers: {}, bodyText: JSON.stringify(successPayload({ + plan_name: undefined, model_remains: [ { model_name: "MiniMax-M2", @@ -197,8 +199,10 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20 - expect(result.plan).toBe("Plus (CN)") + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.plan).toBeUndefined() const first = ctx.host.http.request.mock.calls[0][0].url const last = ctx.host.http.request.mock.calls[ctx.host.http.request.mock.calls.length - 1][0].url expect(first).toBe(PRIMARY_USAGE_URL) @@ -214,6 +218,7 @@ describe("minimax plugin", () => { if (req.url === LEGACY_WWW_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" } if (req.url === CN_PRIMARY_USAGE_URL) return { status: 401, headers: {}, bodyText: "" } if (req.url === CN_FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" } + if (req.url === CN_LEGACY_FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" } return { status: 404, headers: {}, bodyText: "{}" } }) @@ -230,6 +235,7 @@ describe("minimax plugin", () => { if (req.url === LEGACY_WWW_USAGE_URL) return { status: 401, headers: {}, bodyText: "" } if (req.url === CN_PRIMARY_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" } if (req.url === CN_FALLBACK_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" } + if (req.url === CN_LEGACY_FALLBACK_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" } return { status: 404, headers: {}, bodyText: "{}" } }) @@ -254,15 +260,15 @@ describe("minimax plugin", () => { const line = result.lines[0] expect(line.label).toBe("Session") expect(line.type).toBe("progress") - expect(line.used).toBe(120) // current_interval_usage_count is remaining + expect(line.used).toBe(120) expect(line.limit).toBe(300) expect(line.format.kind).toBe("count") - expect(line.format.suffix).toBe("prompts") + expect(line.format.suffix).toBe("model-calls") expect(line.resetsAt).toBe("2023-11-15T03:13:20.000Z") expect(line.periodDurationMs).toBe(18000000) }) - it("treats current_interval_usage_count as remaining prompts", async () => { + it("treats current_interval_usage_count as remaining model-calls", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ @@ -285,6 +291,7 @@ describe("minimax plugin", () => { expect(result.lines[0].used).toBe(0) expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("infers Starter plan from 1500 model-call limit", async () => { @@ -311,6 +318,61 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Starter (GLOBAL)") expect(result.lines[0].used).toBe(300) expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("does not infer a GLOBAL plan from ambiguous 300 prompt limit", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 300, + current_interval_usage_count: 120, + model_name: "MiniMax-M2.5", + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBeUndefined() + expect(result.lines[0].used).toBe(180) + expect(result.lines[0].limit).toBe(300) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("infers Ultra-High-Speed plan from 2000 prompt limit", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 2000, + current_interval_usage_count: 1500, + model_name: "MiniMax-M2.5-highspeed", + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra-High-Speed (GLOBAL)") + expect(result.lines[0].used).toBe(500) + expect(result.lines[0].limit).toBe(2000) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("does not fallback to model name when plan cannot be inferred", async () => { @@ -336,6 +398,7 @@ describe("minimax plugin", () => { expect(result.plan).toBeUndefined() expect(result.lines[0].used).toBe(337) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("supports nested payload and remains_time reset fallback", async () => { @@ -368,6 +431,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Max (GLOBAL)") expect(line.used).toBe(60) expect(line.limit).toBe(100) + expect(line.format.suffix).toBe("model-calls") expect(line.resetsAt).toBe(expectedReset) }) @@ -398,6 +462,7 @@ describe("minimax plugin", () => { expect(line.used).toBe(45) expect(line.limit).toBe(100) + expect(line.format.suffix).toBe("model-calls") expect(line.resetsAt).toBe(new Date(1700000000000 + 300000).toISOString()) }) @@ -427,6 +492,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Pro (GLOBAL)") expect(line.used).toBe(180) expect(line.limit).toBe(300) + expect(line.format.suffix).toBe("model-calls") }) it("throws on HTTP auth status", async () => { @@ -441,7 +507,7 @@ describe("minimax plugin", () => { message = String(e) } expect(message).toContain("Session expired") - expect(ctx.host.http.request.mock.calls.length).toBe(5) + expect(ctx.host.http.request.mock.calls.length).toBe(6) }) it("falls back to secondary endpoint when primary fails", async () => { @@ -463,6 +529,7 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(120) + expect(result.lines[0].format.suffix).toBe("model-calls") expect(ctx.host.http.request.mock.calls.length).toBe(2) }) @@ -494,7 +561,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20 + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].format.suffix).toBe("model-calls") expect(ctx.host.http.request.mock.calls.length).toBe(2) expect(ctx.host.http.request.mock.calls[0][0].url).toBe(CN_PRIMARY_USAGE_URL) expect(ctx.host.http.request.mock.calls[1][0].url).toBe(CN_FALLBACK_USAGE_URL) @@ -526,11 +595,106 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Starter (CN)") - expect(result.lines[0].limit).toBe(40) // 600 / 15 = 40 prompts - expect(result.lines[0].used).toBe(7) // (600-500) / 15 = 6.67 ≈ 7 + expect(result.lines[0].limit).toBe(600) + expect(result.lines[0].used).toBe(100) + expect(result.lines[0].format.suffix).toBe("model-calls") }) - it("infers CN Plus plan from 1500 model-call limit", async () => { + it("keeps raw CN session counts when explicit plan metadata is present", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + plan_name: "Plus", + model_remains: [ + { + model_name: "MiniMax-M2.5", + current_interval_total_count: 100, + current_interval_usage_count: 70, + start_time: 1700000000000, + end_time: 1700018000000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus (CN)") + expect(result.lines).toHaveLength(1) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(30) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("shows extra CN token-plan resource lines for Text to Speech HD and image-01", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + data: { + base_resp: { status_code: 0 }, + current_subscribe_title: "Plus", + model_remains: [ + { + model_name: "MiniMax-M2.5", + current_interval_total_count: 100, + current_interval_usage_count: 70, + start_time: 1700000000000, + end_time: 1700018000000, + }, + { + model_name: "Text to Speech HD", + current_interval_total_count: 2500000, + current_interval_usage_count: 2000000, + start_time: 1700000000000, + end_time: 1700086400000, + }, + { + model_name: "image-01", + current_interval_total_count: 1000, + current_interval_usage_count: 900, + start_time: 1700000000000, + end_time: 1700086400000, + }, + ], + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus (CN)") + expect(result.lines).toHaveLength(3) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 30, + limit: 100, + format: { kind: "count", suffix: "model-calls" }, + }) + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + used: 500000, + limit: 2500000, + format: { kind: "count", suffix: "chars" }, + }) + expect(result.lines[2]).toMatchObject({ + label: "image-01", + used: 100, + limit: 1000, + format: { kind: "count", suffix: "images" }, + }) + }) + + it("does not infer an ambiguous CN plan from 1500 model-call limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) ctx.host.http.request.mockReturnValue({ @@ -555,12 +719,13 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Plus (CN)") - expect(result.lines[0].limit).toBe(100) // 1500 / 15 = 100 prompts - expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20 + expect(result.plan).toBeUndefined() + expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].format.suffix).toBe("model-calls") }) - it("infers CN Max plan from 4500 model-call limit", async () => { + it("does not infer an ambiguous CN plan from 4500 model-call limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) ctx.host.http.request.mockReturnValue({ @@ -585,9 +750,42 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Max (CN)") - expect(result.lines[0].limit).toBe(300) // 4500 / 15 = 300 prompts - expect(result.lines[0].used).toBe(120) // (4500-2700) / 15 = 120 + expect(result.plan).toBeUndefined() + expect(result.lines[0].limit).toBe(4500) + expect(result.lines[0].used).toBe(1800) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("normalizes CN explicit high-speed plan labels to the shared six-plan naming", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + data: { + base_resp: { status_code: 0 }, + current_subscribe_title: "Plus-极速版", + model_remains: [ + { + model_name: "MiniMax-M2.5-highspeed", + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + start_time: 1700000000000, + end_time: 1700018000000, + }, + ], + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (CN)") + expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("does not infer CN plan for unknown CN model-call limits", async () => { @@ -616,8 +814,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBeUndefined() - expect(result.lines[0].limit).toBe(600) // 9000 / 15 = 600 prompts - expect(result.lines[0].used).toBe(200) // (9000-6000) / 15 = 200 prompts + expect(result.lines[0].limit).toBe(9000) + expect(result.lines[0].used).toBe(3000) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("falls back when primary returns auth-like status", async () => { @@ -640,6 +839,7 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(120) + expect(result.lines[0].format.suffix).toBe("model-calls") expect(ctx.host.http.request.mock.calls.length).toBe(2) }) @@ -694,6 +894,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(120) + expect(result.lines[0].limit).toBe(300) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("supports camelCase modelRemains and explicit used count fields", async () => { @@ -719,8 +921,6 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) const line = result.lines[0] - expect(line.used).toBe(123) - expect(line.limit).toBe(500) expect(line.resetsAt).toBe(new Date(1700000000000 + 7200000).toISOString()) expect(line.periodDurationMs).toBeUndefined() }) @@ -813,6 +1013,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Team (GLOBAL)") expect(result.lines[0].used).toBe(180) expect(result.lines[0].limit).toBe(300) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("clamps negative used counts to zero", async () => { @@ -835,6 +1036,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(0) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("clamps used counts above total", async () => { @@ -857,6 +1059,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(100) + expect(result.lines[0].format.suffix).toBe("model-calls") }) it("supports epoch seconds for start/end timestamps", async () => { From 04e55ed6a2e6156c5c93cdd694335956ec69d98d Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 23 Mar 2026 14:16:20 +0000 Subject: [PATCH 02/16] fix(minimax): infer exact plans from companion quotas --- docs/providers/minimax.md | 25 ++-- plugins/minimax/plugin.js | 199 +++++++++++++++++++++++------- plugins/minimax/plugin.test.js | 214 ++++++++++++++++++++++++++++++--- 3 files changed, 364 insertions(+), 74 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index d5f49cd1..7400a1a8 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -76,22 +76,25 @@ Expected payload fields: - `Plus-High-Speed` - `Max-High-Speed` - `Ultra-High-Speed` -- If plan fields are missing in GLOBAL mode, infer only unambiguous plan tiers from known limits: - - `100` prompts or `1500` raw model-calls => `Starter` - - `2000` prompts or `30000` raw model-calls => `Ultra-High-Speed` -- Do not infer a GLOBAL plan from ambiguous limits (`300/1000` prompts or `4500/15000` raw model-calls), because current public docs expose both Standard and High-Speed plans for those quotas. -- In CN mode, infer only unambiguous raw model-call tiers from the CN subscription table: - - `600` => `Starter` - - `30000` => `Ultra-High-Speed` -- Do not infer a CN plan from ambiguous limits (`1500/4500` raw model-calls), because CN standard and CN High-Speed plans overlap on those quotas. -- In CN mode, additional `model_remains[]` entries may appear as separate daily resource buckets, for example `Text to Speech HD` or `image-01`. +- If plan fields are missing, infer the plan tier from the current Token Plan quota table for the selected region: + - `GLOBAL` raw `model-calls`: `1500 => Starter`, `4500 => Plus`, `15000 => Max`, `30000 => Ultra-High-Speed` + - `GLOBAL` legacy prompt-sized payloads: `100 => Starter`, `300 => Plus`, `1000 => Max`, `2000 => Ultra-High-Speed` + - `CN` raw `model-calls`: `600 => Starter`, `1500 => Plus`, `4500 => Max`, `30000 => Ultra-High-Speed` + - `CN` legacy prompt-sized payloads: `40 => Starter`, `100 => Plus`, `300 => Max`, `2000 => Ultra-High-Speed` +- For overlapping middle tiers, the plugin also inspects companion daily quotas when present to disambiguate `Standard` vs `High-Speed`: + - `GLOBAL 4500`: `image-01 50` or `Speech 2.8 4000` => `Plus`; `image-01 100` or `Speech 2.8 9000` => `Plus-High-Speed` + - `GLOBAL 15000`: `image-01 120` or `Speech 2.8 11000` => `Max`; `image-01 200` or `Speech 2.8 19000` => `Max-High-Speed` + - `CN 1500`: `image-01 50` or `speech-hd 4000` => `Plus`; `image-01 100` or `speech-hd 9000` => `Plus-High-Speed` + - `CN 4500`: `image-01 120` or `speech-hd 11000` => `Max`; `image-01 200` or `speech-hd 19000` => `Max-High-Speed` +- If those companion quotas are absent or conflicting, the plugin falls back to the coarse family label (`Plus` / `Max`) instead of guessing. +- In CN mode, additional `model_remains[]` entries may appear as separate daily resource buckets, for example `speech-hd` (`Text to Speech HD`) or `image-01`. - Use `end_time` for reset timestamp when present. - Fallback to `remains_time` when `end_time` is absent. - Use `start_time` + `end_time` as `periodDurationMs` when both are valid. - Historical note: MiniMax public docs and pricing copy still describe Coding Plan in `prompts`, but the plugin follows the raw remains reading and labels the main text session as `model-calls`. - Official package tables used for this split, checked on 2026-03-23: - - Global: - - CN: + - Global: + - CN: ## Output diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index a1800c1c..7ec62377 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -14,25 +14,51 @@ const CODING_PLAN_WINDOW_MS = 5 * 60 * 60 * 1000 const CODING_PLAN_WINDOW_TOLERANCE_MS = 10 * 60 * 1000 const DAILY_WINDOW_MS = 24 * 60 * 60 * 1000 - // Unambiguous prompt-based tiers kept for compatibility with older docs/examples. - const UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN = { + const GLOBAL_PROMPT_LIMIT_TO_PLAN = { 100: "Starter", + 300: "Plus", + 1000: "Max", 2000: "Ultra-High-Speed", } - // Raw model-call tiers inferred from the current Global six-package lineup. - // 4500 and 15000 are ambiguous between Standard and High-Speed variants. - const GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN = { + const GLOBAL_MODEL_CALL_LIMIT_TO_PLAN = { 1500: "Starter", + 4500: "Plus", + 15000: "Max", 30000: "Ultra-High-Speed", } - // Raw model-call tiers inferred from the current CN six-package lineup. - // 1500 and 4500 are ambiguous between Standard and High-Speed variants. - const CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN = { + const CN_PROMPT_LIMIT_TO_PLAN = { + 40: "Starter", + 100: "Plus", + 300: "Max", + 2000: "Ultra-High-Speed", + } + const CN_MODEL_CALL_LIMIT_TO_PLAN = { 600: "Starter", + 1500: "Plus", + 4500: "Max", 30000: "Ultra-High-Speed", } + const GLOBAL_COMPANION_QUOTA_HINTS = { + 4500: { + image01: { 50: "Plus", 100: "Plus-High-Speed" }, + speechHd: { 4000: "Plus", 9000: "Plus-High-Speed" }, + }, + 15000: { + image01: { 120: "Max", 200: "Max-High-Speed" }, + speechHd: { 11000: "Max", 19000: "Max-High-Speed" }, + }, + } + const CN_COMPANION_QUOTA_HINTS = { + 1500: { + image01: { 50: "Plus", 100: "Plus-High-Speed" }, + speechHd: { 4000: "Plus", 9000: "Plus-High-Speed" }, + }, + 4500: { + image01: { 120: "Max", 200: "Max-High-Speed" }, + speechHd: { 11000: "Max", 19000: "Max-High-Speed" }, + }, + } const MODEL_CALLS_SUFFIX = "model-calls" - const MODEL_CALLS_PER_PROMPT = 15 function readString(value) { if (typeof value !== "string") return null @@ -88,21 +114,118 @@ const normalized = Math.round(n) if (endpointSelection === "CN") { - if (CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized]) { - return CN_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized] - } - return null - } else if (GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized]) { - return GLOBAL_UNAMBIGUOUS_MODEL_CALL_LIMIT_TO_PLAN[normalized] + return CN_MODEL_CALL_LIMIT_TO_PLAN[normalized] || CN_PROMPT_LIMIT_TO_PLAN[normalized] || null + } + return GLOBAL_MODEL_CALL_LIMIT_TO_PLAN[normalized] || GLOBAL_PROMPT_LIMIT_TO_PLAN[normalized] || null + } + + function readUsageRawName(item) { + return normalizeUsageName( + pickFirstString([ + item.model_name, + item.modelName, + item.resource_name, + item.resourceName, + item.name, + ]) + ) + } + + function normalizeUsageNameKey(value) { + return value ? value.toLowerCase() : "" + } + + function isSpeechHdUsageName(name) { + return ( + name.includes("text to speech hd") || + name.includes("speech 2.8") || + /^speech(?:-[\d.]+)?-hd$/.test(name) + ) + } + + function isSpeechTurboUsageName(name) { + return ( + name.includes("text to speech turbo") || + /^speech(?:-[\d.]+)?-turbo$/.test(name) + ) + } + + function isImage01UsageName(name) { + return name.includes("image-01") + } + + function isSessionUsageName(name) { + return ( + name.includes("minimax-m") || + name.includes("text model") || + name.includes("coding") + ) + } + + function inferPlanNameFromSignals(signals, endpointSelection) { + const sessionTotal = readNumber(signals && signals.sessionTotal) + if (sessionTotal === null || sessionTotal <= 0) return null + + const basePlanName = inferPlanNameFromLimit(sessionTotal, endpointSelection) + if (!basePlanName) return null + + const hintTable = + endpointSelection === "CN" ? CN_COMPANION_QUOTA_HINTS : GLOBAL_COMPANION_QUOTA_HINTS + const hintSpec = hintTable[Math.round(sessionTotal)] + if (!hintSpec) return basePlanName + + const image01Total = readNumber(signals.image01Total) + const speechHdTotal = readNumber(signals.speechHdTotal) + const candidates = [] + + if (image01Total !== null) { + const planFromImage = hintSpec.image01[Math.round(image01Total)] + if (planFromImage) candidates.push(planFromImage) + } + if (speechHdTotal !== null) { + const planFromSpeech = hintSpec.speechHd[Math.round(speechHdTotal)] + if (planFromSpeech) candidates.push(planFromSpeech) } - if (UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[normalized]) { - return UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[normalized] + if (candidates.length === 0) return basePlanName + if (candidates.every((candidate) => candidate === candidates[0])) return candidates[0] + return basePlanName + } + + function collectPlanInferenceSignals(modelRemains) { + const signals = { + sessionTotal: null, + speechHdTotal: null, + image01Total: null, + } + let fallbackSessionTotal = null + + for (let i = 0; i < modelRemains.length; i += 1) { + const item = modelRemains[i] + if (!item || typeof item !== "object") continue + + const total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) + if (total === null || total <= 0) continue + + const normalizedTotal = Math.round(total) + if (fallbackSessionTotal === null) fallbackSessionTotal = normalizedTotal + + const name = normalizeUsageNameKey(readUsageRawName(item)) + if (signals.speechHdTotal === null && isSpeechHdUsageName(name)) { + signals.speechHdTotal = normalizedTotal + continue + } + if (signals.image01Total === null && isImage01UsageName(name)) { + signals.image01Total = normalizedTotal + continue + } + if (signals.sessionTotal === null && isSessionUsageName(name)) { + signals.sessionTotal = normalizedTotal + } } - if (normalized % MODEL_CALLS_PER_PROMPT !== 0) return null - const inferredPromptLimit = normalized / MODEL_CALLS_PER_PROMPT - return UNAMBIGUOUS_PROMPT_LIMIT_TO_PLAN[inferredPromptLimit] || null + if (signals.sessionTotal === null) signals.sessionTotal = fallbackSessionTotal + return signals } function normalizeUsageName(value) { @@ -112,44 +235,26 @@ } function classifyUsageEntry(item, endpointSelection, index) { - const rawName = normalizeUsageName( - pickFirstString([ - item.model_name, - item.modelName, - item.resource_name, - item.resourceName, - item.name, - ]) - ) - const name = rawName ? rawName.toLowerCase() : "" + const rawName = readUsageRawName(item) + const name = normalizeUsageNameKey(rawName) if (endpointSelection !== "CN") { return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } } - if ( - name.includes("text to speech hd") || - /^speech-[\d.]+-hd$/.test(name) - ) { + if (isSpeechHdUsageName(name)) { return { label: "Text to Speech HD", suffix: "chars", isSession: false } } - if ( - name.includes("text to speech turbo") || - /^speech-[\d.]+-turbo$/.test(name) - ) { + if (isSpeechTurboUsageName(name)) { return { label: "Text to Speech Turbo", suffix: "chars", isSession: false } } - if (name.includes("image-01")) { + if (isImage01UsageName(name)) { return { label: "image-01", suffix: "images", isSession: false } } if (name.includes("image generation")) { return { label: "Image Generation", suffix: "images", isSession: false } } - if ( - name.includes("minimax-m") || - name.includes("text model") || - name.includes("coding") - ) { + if (isSessionUsageName(name)) { return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } } if (index === 0) { @@ -421,8 +526,10 @@ payload.plan_name, payload.plan, ])) - const sessionEntry = entries.find((entry) => entry.label === "Session") || entries[0] - const inferredPlanName = inferPlanNameFromLimit(sessionEntry.total, endpointSelection) + const inferredPlanName = inferPlanNameFromSignals( + collectPlanInferenceSignals(modelRemains), + endpointSelection + ) const planName = explicitPlanName || inferredPlanName return { diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 4979547b..69860104 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -202,7 +202,7 @@ describe("minimax plugin", () => { expect(result.lines[0].used).toBe(300) expect(result.lines[0].limit).toBe(1500) expect(result.lines[0].format.suffix).toBe("model-calls") - expect(result.plan).toBeUndefined() + expect(result.plan).toBe("Plus (CN)") const first = ctx.host.http.request.mock.calls[0][0].url const last = ctx.host.http.request.mock.calls[ctx.host.http.request.mock.calls.length - 1][0].url expect(first).toBe(PRIMARY_USAGE_URL) @@ -321,7 +321,125 @@ describe("minimax plugin", () => { expect(result.lines[0].format.suffix).toBe("model-calls") }) - it("does not infer a GLOBAL plan from ambiguous 300 prompt limit", async () => { + it("infers Plus tier from 4500 GLOBAL model-call limit", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + model_name: "MiniMax-M2.7", + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus (GLOBAL)") + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].limit).toBe(4500) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("infers Max tier from 15000 GLOBAL model-call limit", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 15000, + current_interval_usage_count: 12000, + model_name: "MiniMax-M2.7", + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Max (GLOBAL)") + expect(result.lines[0].used).toBe(3000) + expect(result.lines[0].limit).toBe(15000) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("infers GLOBAL Plus-High-Speed from companion image-01 quota", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + }, + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 100, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") + expect(result.lines).toHaveLength(1) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(4500) + }) + + it("infers GLOBAL Max-High-Speed from companion speech quota", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 15000, + current_interval_usage_count: 12000, + }, + { + model_name: "speech-hd", + current_interval_total_count: 19000, + current_interval_usage_count: 19000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Max-High-Speed (GLOBAL)") + expect(result.lines).toHaveLength(1) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(15000) + }) + + it("infers Plus tier from 300 GLOBAL prompt limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ @@ -342,7 +460,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBeUndefined() + expect(result.plan).toBe("Plus (GLOBAL)") expect(result.lines[0].used).toBe(180) expect(result.lines[0].limit).toBe(300) expect(result.lines[0].format.suffix).toBe("model-calls") @@ -632,7 +750,7 @@ describe("minimax plugin", () => { expect(result.lines[0].format.suffix).toBe("model-calls") }) - it("shows extra CN token-plan resource lines for Text to Speech HD and image-01", async () => { + it("shows extra CN token-plan resource lines for speech-hd and image-01", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) ctx.host.http.request.mockReturnValue({ @@ -651,16 +769,16 @@ describe("minimax plugin", () => { end_time: 1700018000000, }, { - model_name: "Text to Speech HD", - current_interval_total_count: 2500000, - current_interval_usage_count: 2000000, + model_name: "speech-hd", + current_interval_total_count: 4000, + current_interval_usage_count: 3200, start_time: 1700000000000, end_time: 1700086400000, }, { model_name: "image-01", - current_interval_total_count: 1000, - current_interval_usage_count: 900, + current_interval_total_count: 50, + current_interval_usage_count: 40, start_time: 1700000000000, end_time: 1700086400000, }, @@ -682,19 +800,19 @@ describe("minimax plugin", () => { }) expect(result.lines[1]).toMatchObject({ label: "Text to Speech HD", - used: 500000, - limit: 2500000, + used: 800, + limit: 4000, format: { kind: "count", suffix: "chars" }, }) expect(result.lines[2]).toMatchObject({ label: "image-01", - used: 100, - limit: 1000, + used: 10, + limit: 50, format: { kind: "count", suffix: "images" }, }) }) - it("does not infer an ambiguous CN plan from 1500 model-call limit", async () => { + it("infers Plus tier from 1500 CN model-call limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) ctx.host.http.request.mockReturnValue({ @@ -719,13 +837,13 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBeUndefined() + expect(result.plan).toBe("Plus (CN)") expect(result.lines[0].limit).toBe(1500) expect(result.lines[0].used).toBe(300) expect(result.lines[0].format.suffix).toBe("model-calls") }) - it("does not infer an ambiguous CN plan from 4500 model-call limit", async () => { + it("infers Max tier from 4500 CN model-call limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) ctx.host.http.request.mockReturnValue({ @@ -750,12 +868,74 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBeUndefined() + expect(result.plan).toBe("Max (CN)") expect(result.lines[0].limit).toBe(4500) expect(result.lines[0].used).toBe(1800) expect(result.lines[0].format.suffix).toBe("model-calls") }) + it("infers CN Plus-High-Speed from companion image-01 quota", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M*", + current_interval_total_count: 1500, + current_interval_usage_count: 1466, + }, + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 100, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (CN)") + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(1500) + }) + + it("infers CN Max-High-Speed from companion speech quota", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M*", + current_interval_total_count: 4500, + current_interval_usage_count: 4000, + }, + { + model_name: "speech-hd", + current_interval_total_count: 19000, + current_interval_usage_count: 19000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Max-High-Speed (CN)") + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(4500) + }) + it("normalizes CN explicit high-speed plan labels to the shared six-plan naming", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) From 97cfb6de650f251038449cb5507e69355ce10582 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 23 Mar 2026 14:23:30 +0000 Subject: [PATCH 03/16] fix(minimax): drop prompt-based plan inference --- docs/providers/minimax.md | 7 +- plugins/minimax/plugin.js | 16 +-- plugins/minimax/plugin.test.js | 180 +++++++++++++++++++++++---------- 3 files changed, 130 insertions(+), 73 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 7400a1a8..b79487ea 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -9,7 +9,6 @@ - **Auth:** `Authorization: Bearer ` - **Window model:** dynamic rolling 5-hour limit (per MiniMax Coding Plan docs) - **Display note:** OpenUsage shows the raw text-session counts from the remains API as `model-calls`, because that matches the observed official usage display. -- **Docs note:** as of 2026-03-23, MiniMax public pricing/FAQ pages still describe Coding Plan in `prompts`, so this provider doc explains the mismatch explicitly. - **CN note:** current CN docs use `https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains`. ## Authentication @@ -65,7 +64,7 @@ Expected payload fields: ## Usage Mapping -- Treat `current_interval_usage_count` as remaining prompts (MiniMax remains API behavior). +- Treat `current_interval_usage_count` as the remaining raw session/resource count returned by the remains API. - For the main text `Session` line, OpenUsage displays the raw remains numbers as `model-calls` rather than converting them to `prompts`. - If only remaining aliases are provided, compute `used = total - remaining`. - If explicit used-count fields are provided, prefer them. @@ -78,9 +77,7 @@ Expected payload fields: - `Ultra-High-Speed` - If plan fields are missing, infer the plan tier from the current Token Plan quota table for the selected region: - `GLOBAL` raw `model-calls`: `1500 => Starter`, `4500 => Plus`, `15000 => Max`, `30000 => Ultra-High-Speed` - - `GLOBAL` legacy prompt-sized payloads: `100 => Starter`, `300 => Plus`, `1000 => Max`, `2000 => Ultra-High-Speed` - `CN` raw `model-calls`: `600 => Starter`, `1500 => Plus`, `4500 => Max`, `30000 => Ultra-High-Speed` - - `CN` legacy prompt-sized payloads: `40 => Starter`, `100 => Plus`, `300 => Max`, `2000 => Ultra-High-Speed` - For overlapping middle tiers, the plugin also inspects companion daily quotas when present to disambiguate `Standard` vs `High-Speed`: - `GLOBAL 4500`: `image-01 50` or `Speech 2.8 4000` => `Plus`; `image-01 100` or `Speech 2.8 9000` => `Plus-High-Speed` - `GLOBAL 15000`: `image-01 120` or `Speech 2.8 11000` => `Max`; `image-01 200` or `Speech 2.8 19000` => `Max-High-Speed` @@ -91,7 +88,7 @@ Expected payload fields: - Use `end_time` for reset timestamp when present. - Fallback to `remains_time` when `end_time` is absent. - Use `start_time` + `end_time` as `periodDurationMs` when both are valid. -- Historical note: MiniMax public docs and pricing copy still describe Coding Plan in `prompts`, but the plugin follows the raw remains reading and labels the main text session as `model-calls`. +- Prompt-based marketing copy is ignored by the plugin; all inference is based on raw remains quotas and companion resource buckets. - Official package tables used for this split, checked on 2026-03-23: - Global: - CN: diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 7ec62377..3742b899 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -14,24 +14,12 @@ const CODING_PLAN_WINDOW_MS = 5 * 60 * 60 * 1000 const CODING_PLAN_WINDOW_TOLERANCE_MS = 10 * 60 * 1000 const DAILY_WINDOW_MS = 24 * 60 * 60 * 1000 - const GLOBAL_PROMPT_LIMIT_TO_PLAN = { - 100: "Starter", - 300: "Plus", - 1000: "Max", - 2000: "Ultra-High-Speed", - } const GLOBAL_MODEL_CALL_LIMIT_TO_PLAN = { 1500: "Starter", 4500: "Plus", 15000: "Max", 30000: "Ultra-High-Speed", } - const CN_PROMPT_LIMIT_TO_PLAN = { - 40: "Starter", - 100: "Plus", - 300: "Max", - 2000: "Ultra-High-Speed", - } const CN_MODEL_CALL_LIMIT_TO_PLAN = { 600: "Starter", 1500: "Plus", @@ -114,9 +102,9 @@ const normalized = Math.round(n) if (endpointSelection === "CN") { - return CN_MODEL_CALL_LIMIT_TO_PLAN[normalized] || CN_PROMPT_LIMIT_TO_PLAN[normalized] || null + return CN_MODEL_CALL_LIMIT_TO_PLAN[normalized] || null } - return GLOBAL_MODEL_CALL_LIMIT_TO_PLAN[normalized] || GLOBAL_PROMPT_LIMIT_TO_PLAN[normalized] || null + return GLOBAL_MODEL_CALL_LIMIT_TO_PLAN[normalized] || null } function readUsageRawName(item) { diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 69860104..e39757c6 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -439,60 +439,6 @@ describe("minimax plugin", () => { expect(result.lines[0].limit).toBe(15000) }) - it("infers Plus tier from 300 GLOBAL prompt limit", async () => { - const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify({ - base_resp: { status_code: 0 }, - model_remains: [ - { - current_interval_total_count: 300, - current_interval_usage_count: 120, - model_name: "MiniMax-M2.5", - }, - ], - }), - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Plus (GLOBAL)") - expect(result.lines[0].used).toBe(180) - expect(result.lines[0].limit).toBe(300) - expect(result.lines[0].format.suffix).toBe("model-calls") - }) - - it("infers Ultra-High-Speed plan from 2000 prompt limit", async () => { - const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify({ - base_resp: { status_code: 0 }, - model_remains: [ - { - current_interval_total_count: 2000, - current_interval_usage_count: 1500, - model_name: "MiniMax-M2.5-highspeed", - }, - ], - }), - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Ultra-High-Speed (GLOBAL)") - expect(result.lines[0].used).toBe(500) - expect(result.lines[0].limit).toBe(2000) - expect(result.lines[0].format.suffix).toBe("model-calls") - }) - it("does not fallback to model name when plan cannot be inferred", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) @@ -936,6 +882,63 @@ describe("minimax plugin", () => { expect(result.lines[0].limit).toBe(4500) }) + it("falls back to the coarse CN tier when companion quotas conflict", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M*", + current_interval_total_count: 1500, + current_interval_usage_count: 1400, + }, + { + model_name: "speech-hd", + current_interval_total_count: 9000, + current_interval_usage_count: 9000, + }, + { + model_name: "image-01", + current_interval_total_count: 50, + current_interval_usage_count: 50, + }, + { + model_name: "speech-2.8-turbo", + current_interval_total_count: 8000, + current_interval_usage_count: 7900, + }, + { + model_name: "Image Generation", + current_interval_total_count: 25, + current_interval_usage_count: 24, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus (CN)") + expect(result.lines).toHaveLength(5) + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + format: { kind: "count", suffix: "chars" }, + }) + expect(result.lines[3]).toMatchObject({ + label: "Text to Speech Turbo", + format: { kind: "count", suffix: "chars" }, + }) + expect(result.lines[4]).toMatchObject({ + label: "Image Generation", + format: { kind: "count", suffix: "images" }, + }) + }) + it("normalizes CN explicit high-speed plan labels to the shared six-plan naming", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) @@ -1078,6 +1081,26 @@ describe("minimax plugin", () => { expect(result.lines[0].format.suffix).toBe("model-calls") }) + it("falls back to GLOBAL when MINIMAX_CN_API_KEY lookup throws in AUTO mode", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "MINIMAX_CN_API_KEY") throw new Error("cn env unavailable") + if (name === "MINIMAX_API_KEY") return "global-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify(successPayload()), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(ctx.host.http.request.mock.calls[0][0].url).toBe(PRIMARY_USAGE_URL) + expect(result.plan).toBe("Plus (GLOBAL)") + }) + it("supports camelCase modelRemains and explicit used count fields", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) @@ -1292,6 +1315,55 @@ describe("minimax plugin", () => { expect(result.lines[0].resetsAt).toBe(new Date(1700000000000 + 300000).toISOString()) }) + it("prefers milliseconds remains_time when end_time makes it a closer match", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 100, + current_interval_usage_count: 40, + remains_time: 300000, + end_time: 1700000300000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].resetsAt).toBe(new Date(1700000300000).toISOString()) + }) + + it("uses overflow comparison when remains_time exceeds the expected window", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + current_interval_total_count: 100, + current_interval_usage_count: 40, + remains_time: 20000000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].resetsAt).toBe(new Date(1700000000000 + 20000000).toISOString()) + }) + it("throws parse error when model_remains entries are unusable", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) From 6dd2e6e8046d9c0f9b2095c555a86b51b003f2e4 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 23 Mar 2026 14:33:29 +0000 Subject: [PATCH 04/16] fix(minimax): prefer session entries in global remains --- plugins/minimax/plugin.js | 28 ++++++++++++++++++++++--- plugins/minimax/plugin.test.js | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 3742b899..c5a2ef32 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -459,6 +459,24 @@ } } + function pickGlobalSessionRemainItem(modelRemains) { + let fallbackItem = null + + for (let i = 0; i < modelRemains.length; i += 1) { + const item = modelRemains[i] + if (!item || typeof item !== "object") continue + + const total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) + if (total === null || total <= 0) continue + if (!fallbackItem) fallbackItem = item + + const name = normalizeUsageNameKey(readUsageRawName(item)) + if (isSessionUsageName(name)) return item + } + + return fallbackItem + } + function parsePayloadShape(ctx, payload, endpointSelection) { if (!payload || typeof payload !== "object") return null @@ -493,13 +511,17 @@ const entries = [] const seenLabels = Object.create(null) - for (let i = 0; i < modelRemains.length; i += 1) { - const entry = parseModelRemainEntry(ctx, modelRemains[i], endpointSelection, i) + const remainsToParse = + endpointSelection === "CN" + ? modelRemains + : [pickGlobalSessionRemainItem(modelRemains)] + + for (let i = 0; i < remainsToParse.length; i += 1) { + const entry = parseModelRemainEntry(ctx, remainsToParse[i], endpointSelection, i) if (!entry) continue if (seenLabels[entry.label]) continue seenLabels[entry.label] = true entries.push(entry) - if (endpointSelection !== "CN") break } if (entries.length === 0) return null diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index e39757c6..ea080069 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -407,6 +407,42 @@ describe("minimax plugin", () => { expect(result.lines[0].limit).toBe(4500) }) + it("prefers the GLOBAL session entry when a companion bucket appears first", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 90, + }, + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") + expect(result.lines).toHaveLength(1) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 300, + limit: 4500, + format: { kind: "count", suffix: "model-calls" }, + }) + }) + it("infers GLOBAL Max-High-Speed from companion speech quota", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) @@ -1124,6 +1160,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) const line = result.lines[0] + expect(line.used).toBe(123) + expect(line.limit).toBe(500) expect(line.resetsAt).toBe(new Date(1700000000000 + 7200000).toISOString()) expect(line.periodDurationMs).toBeUndefined() }) From 4ce20ed9ff4e793176876343a8a84ff868f66bf0 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 23 Mar 2026 14:41:00 +0000 Subject: [PATCH 05/16] fix(minimax): render global companion resource lines --- docs/providers/minimax.md | 5 +- plugins/minimax/plugin.js | 38 ++++--- plugins/minimax/plugin.test.js | 190 ++++++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 17 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index b79487ea..d35a8624 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -84,10 +84,11 @@ Expected payload fields: - `CN 1500`: `image-01 50` or `speech-hd 4000` => `Plus`; `image-01 100` or `speech-hd 9000` => `Plus-High-Speed` - `CN 4500`: `image-01 120` or `speech-hd 11000` => `Max`; `image-01 200` or `speech-hd 19000` => `Max-High-Speed` - If those companion quotas are absent or conflicting, the plugin falls back to the coarse family label (`Plus` / `Max`) instead of guessing. -- In CN mode, additional `model_remains[]` entries may appear as separate daily resource buckets, for example `speech-hd` (`Text to Speech HD`) or `image-01`. +- Additional `model_remains[]` companion resource buckets are rendered as separate daily detail lines in both `GLOBAL` and `CN` mode, for example `speech-hd` (`Text to Speech HD`) or `image-01`. - Use `end_time` for reset timestamp when present. - Fallback to `remains_time` when `end_time` is absent. - Use `start_time` + `end_time` as `periodDurationMs` when both are valid. +- Non-session companion resource lines use a daily window when only `remains_time` is present. - Prompt-based marketing copy is ignored by the plugin; all inference is based on raw remains quotas and companion resource buckets. - Official package tables used for this split, checked on 2026-03-23: - Global: @@ -102,7 +103,7 @@ Expected payload fields: - `used`: computed used model-call count from raw remains data - `limit`: raw session limit from the remains payload - `resetsAt`: derived from `end_time` or `remains_time` -- **CN extra resources** (detail progress lines when present): +- **Extra resources** (detail progress lines when present in either region): - `Text to Speech HD` / `Text to Speech Turbo`: count (`chars`) - `Image Generation` / `image-01`: count (`images`) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index c5a2ef32..242fc3de 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -226,10 +226,6 @@ const rawName = readUsageRawName(item) const name = normalizeUsageNameKey(rawName) - if (endpointSelection !== "CN") { - return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } - } - if (isSpeechHdUsageName(name)) { return { label: "Text to Speech HD", suffix: "chars", isSession: false } } @@ -261,7 +257,7 @@ return Math.abs(n) < 1e10 ? n * 1000 : n } - function inferRemainsMs(remainsRaw, endMs, nowMs) { + function inferRemainsMs(remainsRaw, endMs, nowMs, expectedWindowMs) { if (remainsRaw === null || remainsRaw <= 0) return null const asSecondsMs = remainsRaw * 1000 @@ -278,7 +274,8 @@ } // Coding Plan resets every 5h. Use that constraint before defaulting. - const maxExpectedMs = CODING_PLAN_WINDOW_MS + CODING_PLAN_WINDOW_TOLERANCE_MS + const maxExpectedMs = + (expectedWindowMs || CODING_PLAN_WINDOW_MS) + CODING_PLAN_WINDOW_TOLERANCE_MS const secondsLooksValid = asSecondsMs <= maxExpectedMs const millisecondsLooksValid = asMillisecondsMs <= maxExpectedMs @@ -435,7 +432,9 @@ const endMs = epochToMs(item.end_time ?? item.endTime) const remainsRaw = readNumber(item.remains_time ?? item.remainsTime) const nowMs = Date.now() - const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs) + const expectedRemainsWindowMs = + !usageMeta.isSession ? DAILY_WINDOW_MS : CODING_PLAN_WINDOW_MS + const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs, expectedRemainsWindowMs) let resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null if (!resetsAt && remainsMs !== null) { @@ -445,7 +444,7 @@ let periodDurationMs = null if (startMs !== null && endMs !== null && endMs > startMs) { periodDurationMs = endMs - startMs - } else if (endpointSelection === "CN" && !usageMeta.isSession) { + } else if (!usageMeta.isSession) { periodDurationMs = DAILY_WINDOW_MS } @@ -477,6 +476,24 @@ return fallbackItem } + function orderRemainItemsForDisplay(modelRemains, endpointSelection) { + if (!Array.isArray(modelRemains) || modelRemains.length === 0) return [] + + const ordered = [] + const sessionItem = + endpointSelection === "GLOBAL" ? pickGlobalSessionRemainItem(modelRemains) : null + if (sessionItem) ordered.push(sessionItem) + + for (let i = 0; i < modelRemains.length; i += 1) { + const item = modelRemains[i] + if (!item || typeof item !== "object") continue + if (sessionItem && item === sessionItem) continue + ordered.push(item) + } + + return ordered + } + function parsePayloadShape(ctx, payload, endpointSelection) { if (!payload || typeof payload !== "object") return null @@ -511,10 +528,7 @@ const entries = [] const seenLabels = Object.create(null) - const remainsToParse = - endpointSelection === "CN" - ? modelRemains - : [pickGlobalSessionRemainItem(modelRemains)] + const remainsToParse = orderRemainItemsForDisplay(modelRemains, endpointSelection) for (let i = 0; i < remainsToParse.length; i += 1) { const entry = parseModelRemainEntry(ctx, remainsToParse[i], endpointSelection, i) diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index ea080069..713a8692 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -402,9 +402,15 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") - expect(result.lines).toHaveLength(1) + expect(result.lines).toHaveLength(2) expect(result.lines[0].label).toBe("Session") expect(result.lines[0].limit).toBe(4500) + expect(result.lines[1]).toMatchObject({ + label: "image-01", + used: 0, + limit: 100, + format: { kind: "count", suffix: "images" }, + }) }) it("prefers the GLOBAL session entry when a companion bucket appears first", async () => { @@ -434,13 +440,19 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") - expect(result.lines).toHaveLength(1) + expect(result.lines).toHaveLength(2) expect(result.lines[0]).toMatchObject({ label: "Session", used: 300, limit: 4500, format: { kind: "count", suffix: "model-calls" }, }) + expect(result.lines[1]).toMatchObject({ + label: "image-01", + used: 10, + limit: 100, + format: { kind: "count", suffix: "images" }, + }) }) it("infers GLOBAL Max-High-Speed from companion speech quota", async () => { @@ -470,9 +482,129 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Max-High-Speed (GLOBAL)") - expect(result.lines).toHaveLength(1) + expect(result.lines).toHaveLength(2) expect(result.lines[0].label).toBe("Session") expect(result.lines[0].limit).toBe(15000) + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + used: 0, + limit: 19000, + format: { kind: "count", suffix: "chars" }, + }) + }) + + it("shows extra GLOBAL token-plan resource lines for speech-hd and image-01", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + data: { + base_resp: { status_code: 0 }, + current_subscribe_title: "Plus-High-Speed", + model_remains: [ + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + start_time: 1700000000000, + end_time: 1700018000000, + }, + { + model_name: "speech-hd", + current_interval_total_count: 9000, + current_interval_usage_count: 7200, + start_time: 1700000000000, + end_time: 1700086400000, + }, + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 80, + start_time: 1700000000000, + end_time: 1700086400000, + }, + ], + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") + expect(result.lines).toHaveLength(3) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 300, + limit: 4500, + format: { kind: "count", suffix: "model-calls" }, + }) + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + used: 1800, + limit: 9000, + format: { kind: "count", suffix: "chars" }, + }) + expect(result.lines[2]).toMatchObject({ + label: "image-01", + used: 20, + limit: 100, + format: { kind: "count", suffix: "images" }, + }) + }) + + it("uses a daily remains_time window for GLOBAL resource lines without end_time", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + data: { + base_resp: { status_code: 0 }, + current_subscribe_title: "Plus-High-Speed", + model_remains: [ + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + start_time: 1700000000000, + end_time: 1700018000000, + }, + { + model_name: "speech-hd", + current_interval_total_count: 9000, + current_interval_usage_count: 7200, + remains_time: 86400, + }, + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 80, + remains_time: 86400, + }, + ], + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const expectedReset = new Date(1700000000000 + 86400 * 1000).toISOString() + + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + resetsAt: expectedReset, + periodDurationMs: 86400000, + }) + expect(result.lines[2]).toMatchObject({ + label: "image-01", + resetsAt: expectedReset, + periodDurationMs: 86400000, + }) }) it("does not fallback to model name when plan cannot be inferred", async () => { @@ -794,6 +926,58 @@ describe("minimax plugin", () => { }) }) + it("uses a daily remains_time window for CN resource lines without end_time", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + data: { + base_resp: { status_code: 0 }, + current_subscribe_title: "Plus", + model_remains: [ + { + model_name: "MiniMax-M2.5", + current_interval_total_count: 100, + current_interval_usage_count: 70, + start_time: 1700000000000, + end_time: 1700018000000, + }, + { + model_name: "speech-hd", + current_interval_total_count: 4000, + current_interval_usage_count: 3200, + remains_time: 86400, + }, + { + model_name: "image-01", + current_interval_total_count: 50, + current_interval_usage_count: 40, + remains_time: 86400, + }, + ], + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const expectedReset = new Date(1700000000000 + 86400 * 1000).toISOString() + + expect(result.lines[1]).toMatchObject({ + label: "Text to Speech HD", + resetsAt: expectedReset, + periodDurationMs: 86400000, + }) + expect(result.lines[2]).toMatchObject({ + label: "image-01", + resetsAt: expectedReset, + periodDurationMs: 86400000, + }) + }) + it("infers Plus tier from 1500 CN model-call limit", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) From 9274def82760642717bb4c9994f7950dd996d586 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 01:34:25 +0100 Subject: [PATCH 06/16] fix(minimax): improve session bucket selection and CN path handling - Extend isSessionUsageName to match M2.7, M2.7-highspeed, minimax_m patterns - Rename pickGlobalSessionRemainItem to pickSessionRemainItem for CN reuse - Apply session bucket selection to both GLOBAL and CN paths (was GLOBAL only) - Remove unused endpointSelection parameter from classifyUsageEntry - Update inferRemainsMs comment to reflect actual behavior (not just 5h Coding Plan) - Add regression test for CN path with companion bucket appearing first Fixes PR #317 review comments on session bucket selection --- plugins/minimax/plugin.js | 15 ++++++------ plugins/minimax/plugin.test.js | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 242fc3de..c0f9f2c3 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -146,7 +146,9 @@ return ( name.includes("minimax-m") || name.includes("text model") || - name.includes("coding") + name.includes("coding") || + name.includes("m2.7") || + name.includes("minimax_m") ) } @@ -222,7 +224,7 @@ return raw.replace(/\s+/g, " ").trim() } - function classifyUsageEntry(item, endpointSelection, index) { + function classifyUsageEntry(item, index) { const rawName = readUsageRawName(item) const name = normalizeUsageNameKey(rawName) @@ -273,7 +275,7 @@ } } - // Coding Plan resets every 5h. Use that constraint before defaulting. + // Use expectedWindowMs constraint before defaulting. const maxExpectedMs = (expectedWindowMs || CODING_PLAN_WINDOW_MS) + CODING_PLAN_WINDOW_TOLERANCE_MS const secondsLooksValid = asSecondsMs <= maxExpectedMs @@ -391,7 +393,7 @@ function parseModelRemainEntry(ctx, item, endpointSelection, index) { if (!item || typeof item !== "object") return null - const usageMeta = classifyUsageEntry(item, endpointSelection, index) + const usageMeta = classifyUsageEntry(item, index) let total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) if (total === null || total <= 0) return null @@ -458,7 +460,7 @@ } } - function pickGlobalSessionRemainItem(modelRemains) { + function pickSessionRemainItem(modelRemains) { let fallbackItem = null for (let i = 0; i < modelRemains.length; i += 1) { @@ -480,8 +482,7 @@ if (!Array.isArray(modelRemains) || modelRemains.length === 0) return [] const ordered = [] - const sessionItem = - endpointSelection === "GLOBAL" ? pickGlobalSessionRemainItem(modelRemains) : null + const sessionItem = pickSessionRemainItem(modelRemains) if (sessionItem) ordered.push(sessionItem) for (let i = 0; i < modelRemains.length; i += 1) { diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 713a8692..3978c170 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1040,6 +1040,48 @@ describe("minimax plugin", () => { expect(result.lines[0].format.suffix).toBe("model-calls") }) + it("prefers the CN session entry when a companion bucket appears first", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 90, + }, + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (CN)") + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 300, + limit: 1500, + format: { kind: "count", suffix: "model-calls" }, + }) + expect(result.lines[1]).toMatchObject({ + label: "image-01", + used: 10, + limit: 100, + format: { kind: "count", suffix: "images" }, + }) + }) + it("infers CN Plus-High-Speed from companion image-01 quota", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) From 0c7bed465b378a551326e7a80493d9ddf4797b8d Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 01:47:17 +0100 Subject: [PATCH 07/16] refactor(minimax): rename Coding Plan to Token Plan Update all references from CODING_PLAN_* constants to TOKEN_PLAN_* for consistency with MiniMax API rename. Also normalize "MiniMax Coding Plan" to "Token Plan" alongside existing "token plan" handling. --- plugins/minimax/plugin.js | 11 ++++++----- plugins/minimax/plugin.test.js | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index c0f9f2c3..f0a5a2c1 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -11,8 +11,8 @@ ] const GLOBAL_API_KEY_ENV_VARS = ["MINIMAX_API_KEY", "MINIMAX_API_TOKEN"] const CN_API_KEY_ENV_VARS = ["MINIMAX_CN_API_KEY", "MINIMAX_API_KEY", "MINIMAX_API_TOKEN"] - const CODING_PLAN_WINDOW_MS = 5 * 60 * 60 * 1000 - const CODING_PLAN_WINDOW_TOLERANCE_MS = 10 * 60 * 1000 + const TOKEN_PLAN_WINDOW_MS = 5 * 60 * 60 * 1000 + const TOKEN_PLAN_WINDOW_TOLERANCE_MS = 10 * 60 * 1000 const DAILY_WINDOW_MS = 24 * 60 * 60 * 1000 const GLOBAL_MODEL_CALL_LIMIT_TO_PLAN = { 1500: "Starter", @@ -77,7 +77,8 @@ const compact = raw.replace(/\s+/g, " ").trim() const withoutPrefix = compact.replace(/^minimax\s+coding\s+plan\b[:\-]?\s*/i, "").trim() const base = withoutPrefix || compact - if (/coding\s+plan/i.test(compact) && !withoutPrefix) return "Coding Plan" + if (/coding\s+plan/i.test(compact) && !withoutPrefix) return "Token Plan" + if (/token\s+plan/i.test(compact) && !withoutPrefix) return "Token Plan" const canonical = base .replace(/\s*-\s*/g, "-") @@ -277,7 +278,7 @@ // Use expectedWindowMs constraint before defaulting. const maxExpectedMs = - (expectedWindowMs || CODING_PLAN_WINDOW_MS) + CODING_PLAN_WINDOW_TOLERANCE_MS + (expectedWindowMs || TOKEN_PLAN_WINDOW_MS) + TOKEN_PLAN_WINDOW_TOLERANCE_MS const secondsLooksValid = asSecondsMs <= maxExpectedMs const millisecondsLooksValid = asMillisecondsMs <= maxExpectedMs @@ -435,7 +436,7 @@ const remainsRaw = readNumber(item.remains_time ?? item.remainsTime) const nowMs = Date.now() const expectedRemainsWindowMs = - !usageMeta.isSession ? DAILY_WINDOW_MS : CODING_PLAN_WINDOW_MS + !usageMeta.isSession ? DAILY_WINDOW_MS : TOKEN_PLAN_WINDOW_MS const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs, expectedRemainsWindowMs) let resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 3978c170..9018a33a 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1433,7 +1433,7 @@ describe("minimax plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data.") }) - it("normalizes bare 'MiniMax Coding Plan' to 'Coding Plan'", async () => { + it("normalizes bare 'MiniMax Coding Plan' to 'Token Plan'", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ @@ -1453,7 +1453,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Coding Plan (GLOBAL)") + expect(result.plan).toBe("Token Plan (GLOBAL)") }) it("supports payload.modelRemains and remains-count aliases", async () => { From 312de8ff644e6d1ee61dc2fc10f359a18c7908e7 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 01:55:04 +0100 Subject: [PATCH 08/16] test(minimax): add bucket classification and session/regression tests Add 8 new tests covering: - M2.7 and M2.7-highspeed session bucket classification - speech-hd and image-01 NOT classified as session - session bucket selected by name pattern, not order (GLOBAL + CN) - 5h token-plan window for session remains_time inference - daily window for non-session companion buckets Also fix isSessionUsageName to include highspeed pattern per official docs. --- plugins/minimax/plugin.js | 4 +- plugins/minimax/plugin.test.js | 209 +++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index f0a5a2c1..f5c36211 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -149,7 +149,9 @@ name.includes("text model") || name.includes("coding") || name.includes("m2.7") || - name.includes("minimax_m") + name.includes("minimax_m") || + name.includes("highspeed") || + name.includes("high-speed") ) } diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 9018a33a..20cd8bda 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1659,4 +1659,213 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") }) + + it("classifies M2.7-highspeed as session bucket", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7-highspeed", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].used).toBe(300) + expect(result.lines[0].limit).toBe(4500) + expect(result.lines[0].format.suffix).toBe("model-calls") + }) + + it("classifies M2.7 as session bucket", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 15000, + current_interval_usage_count: 12000, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].used).toBe(3000) + expect(result.lines[0].limit).toBe(15000) + }) + + it("does not classify speech-hd as session bucket", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "speech-hd", + current_interval_total_count: 11000, + current_interval_usage_count: 7200, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("Text to Speech HD") + expect(result.lines[0].format.suffix).toBe("chars") + expect(result.lines[0].isSession).toBeUndefined() + }) + + it("does not classify image-01 as session bucket", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 80, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("image-01") + expect(result.lines[0].format.suffix).toBe("images") + }) + + it("selects session bucket by name pattern, not order (GLOBAL)", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { model_name: "speech-hd", current_interval_total_count: 11000, current_interval_usage_count: 7200 }, + { model_name: "MiniMax-M2.7", current_interval_total_count: 15000, current_interval_usage_count: 12000 }, + { model_name: "image-01", current_interval_total_count: 100, current_interval_usage_count: 80 }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(15000) + expect(result.lines[0].used).toBe(3000) + expect(result.lines[1].label).toBe("Text to Speech HD") + expect(result.lines[2].label).toBe("image-01") + }) + + it("selects session bucket by name pattern, not order (CN)", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { model_name: "image-01", current_interval_total_count: 50, current_interval_usage_count: 40 }, + { model_name: "MiniMax-M2.7-highspeed", current_interval_total_count: 1500, current_interval_usage_count: 1200 }, + { model_name: "speech-hd", current_interval_total_count: 4000, current_interval_usage_count: 3200 }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].used).toBe(300) + expect(result.lines[1].label).toBe("image-01") + expect(result.lines[2].label).toBe("Text to Speech HD") + }) + + it("uses 5h token-plan window for session bucket remains_time inference", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + remains_time: 18000, // 18k seconds = 5h, matches TOKEN_PLAN_WINDOW_MS + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].resetsAt).toBeDefined() + // remains_time=18000 is interpreted as seconds (18k*1000=18M ms), close to 5h window + expect(result.lines[0].periodDurationMs).toBeUndefined() // no end_time, so periodDurationMs not set + }) + + it("uses daily window for non-session companion buckets", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + remains_time: 18000, + }, + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 80, + remains_time: 86400, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + // Session bucket has no end_time so periodDurationMs is derived from remains_time + expect(result.lines[1].periodDurationMs).toBe(86400000) + }) }) From d97cf84b401231a33f976c7029b337ec1c884268 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 02:49:45 +0100 Subject: [PATCH 09/16] feat(minimax): show Session as percent like claude/codex plugins Session bucket now displays usage as percentage (used/limit*100) with limit normalized to 100, matching plugins/claude and plugins/codex style. Companion buckets (speech-hd, image-01) remain as counts. Companion bucket assertions updated to use actual API values. --- plugins/minimax/plugin.js | 16 +- plugins/minimax/plugin.test.js | 261 +++++++++++++++++---------------- 2 files changed, 143 insertions(+), 134 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index f5c36211..b91e2811 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -149,9 +149,7 @@ name.includes("text model") || name.includes("coding") || name.includes("m2.7") || - name.includes("minimax_m") || - name.includes("highspeed") || - name.includes("high-speed") + name.includes("minimax_m") ) } @@ -460,6 +458,7 @@ suffix: usageMeta.suffix, resetsAt, periodDurationMs, + isSession: usageMeta.isSession, } } @@ -599,11 +598,16 @@ } const lines = parsed.entries.map((entry) => { + const isSessionLine = entry.isSession + const usedVal = isSessionLine ? Math.round((entry.used / entry.total) * 100) : Math.round(entry.used) + const limitVal = isSessionLine ? 100 : Math.round(entry.total) const line = { label: entry.label, - used: Math.round(entry.used), - limit: Math.round(entry.total), - format: { kind: "count", suffix: entry.suffix }, + used: usedVal, + limit: limitVal, + format: isSessionLine + ? { kind: "percent" } + : { kind: "count", suffix: entry.suffix }, } if (entry.resetsAt) line.resetsAt = entry.resetsAt if (entry.periodDurationMs !== null) line.periodDurationMs = entry.periodDurationMs diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 20cd8bda..1dac497a 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -199,9 +199,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") expect(result.plan).toBe("Plus (CN)") const first = ctx.host.http.request.mock.calls[0][0].url const last = ctx.host.http.request.mock.calls[ctx.host.http.request.mock.calls.length - 1][0].url @@ -260,10 +260,9 @@ describe("minimax plugin", () => { const line = result.lines[0] expect(line.label).toBe("Session") expect(line.type).toBe("progress") - expect(line.used).toBe(120) - expect(line.limit).toBe(300) - expect(line.format.kind).toBe("count") - expect(line.format.suffix).toBe("model-calls") + expect(line.used).toBe(40) + expect(line.limit).toBe(100) + expect(line.format.kind).toBe("percent") expect(line.resetsAt).toBe("2023-11-15T03:13:20.000Z") expect(line.periodDurationMs).toBe(18000000) }) @@ -290,8 +289,8 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(0) - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers Starter plan from 1500 model-call limit", async () => { @@ -316,9 +315,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Starter (GLOBAL)") - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers Plus tier from 4500 GLOBAL model-call limit", async () => { @@ -343,9 +342,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Plus (GLOBAL)") - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].limit).toBe(4500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(7) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers Max tier from 15000 GLOBAL model-call limit", async () => { @@ -370,9 +369,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Max (GLOBAL)") - expect(result.lines[0].used).toBe(3000) - expect(result.lines[0].limit).toBe(15000) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers GLOBAL Plus-High-Speed from companion image-01 quota", async () => { @@ -403,8 +402,12 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Plus-High-Speed (GLOBAL)") expect(result.lines).toHaveLength(2) - expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(4500) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 7, + limit: 100, + format: { kind: "percent" }, + }) expect(result.lines[1]).toMatchObject({ label: "image-01", used: 0, @@ -443,9 +446,9 @@ describe("minimax plugin", () => { expect(result.lines).toHaveLength(2) expect(result.lines[0]).toMatchObject({ label: "Session", - used: 300, - limit: 4500, - format: { kind: "count", suffix: "model-calls" }, + used: 7, + limit: 100, + format: { kind: "percent" }, }) expect(result.lines[1]).toMatchObject({ label: "image-01", @@ -483,8 +486,12 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Max-High-Speed (GLOBAL)") expect(result.lines).toHaveLength(2) - expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(15000) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }) expect(result.lines[1]).toMatchObject({ label: "Text to Speech HD", used: 0, @@ -537,9 +544,9 @@ describe("minimax plugin", () => { expect(result.lines).toHaveLength(3) expect(result.lines[0]).toMatchObject({ label: "Session", - used: 300, - limit: 4500, - format: { kind: "count", suffix: "model-calls" }, + used: 7, + limit: 100, + format: { kind: "percent" }, }) expect(result.lines[1]).toMatchObject({ label: "Text to Speech HD", @@ -629,8 +636,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBeUndefined() - expect(result.lines[0].used).toBe(337) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(25) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("supports nested payload and remains_time reset fallback", async () => { @@ -663,7 +671,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Max (GLOBAL)") expect(line.used).toBe(60) expect(line.limit).toBe(100) - expect(line.format.suffix).toBe("model-calls") + expect(line.format.kind).toBe("percent") expect(line.resetsAt).toBe(expectedReset) }) @@ -694,7 +702,7 @@ describe("minimax plugin", () => { expect(line.used).toBe(45) expect(line.limit).toBe(100) - expect(line.format.suffix).toBe("model-calls") + expect(line.format.kind).toBe("percent") expect(line.resetsAt).toBe(new Date(1700000000000 + 300000).toISOString()) }) @@ -722,9 +730,9 @@ describe("minimax plugin", () => { const line = result.lines[0] expect(result.plan).toBe("Pro (GLOBAL)") - expect(line.used).toBe(180) - expect(line.limit).toBe(300) - expect(line.format.suffix).toBe("model-calls") + expect(line.used).toBe(60) + expect(line.limit).toBe(100) + expect(line.format.kind).toBe("percent") }) it("throws on HTTP auth status", async () => { @@ -760,8 +768,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(120) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(40) + expect(result.lines[0].format.kind).toBe("percent") expect(ctx.host.http.request.mock.calls.length).toBe(2) }) @@ -793,9 +801,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") expect(ctx.host.http.request.mock.calls.length).toBe(2) expect(ctx.host.http.request.mock.calls[0][0].url).toBe(CN_PRIMARY_USAGE_URL) expect(ctx.host.http.request.mock.calls[1][0].url).toBe(CN_FALLBACK_USAGE_URL) @@ -827,9 +835,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Starter (CN)") - expect(result.lines[0].limit).toBe(600) - expect(result.lines[0].used).toBe(100) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(17) + expect(result.lines[0].format.kind).toBe("percent") }) it("keeps raw CN session counts when explicit plan metadata is present", async () => { @@ -861,7 +869,7 @@ describe("minimax plugin", () => { expect(result.lines[0].label).toBe("Session") expect(result.lines[0].limit).toBe(100) expect(result.lines[0].used).toBe(30) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].format.kind).toBe("percent") }) it("shows extra CN token-plan resource lines for speech-hd and image-01", async () => { @@ -910,7 +918,7 @@ describe("minimax plugin", () => { label: "Session", used: 30, limit: 100, - format: { kind: "count", suffix: "model-calls" }, + format: { kind: "percent" }, }) expect(result.lines[1]).toMatchObject({ label: "Text to Speech HD", @@ -1004,9 +1012,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Plus (CN)") - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers Max tier from 4500 CN model-call limit", async () => { @@ -1035,51 +1043,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Max (CN)") - expect(result.lines[0].limit).toBe(4500) - expect(result.lines[0].used).toBe(1800) - expect(result.lines[0].format.suffix).toBe("model-calls") - }) - - it("prefers the CN session entry when a companion bucket appears first", async () => { - const ctx = makeCtx() - setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify({ - base_resp: { status_code: 0 }, - model_remains: [ - { - model_name: "image-01", - current_interval_total_count: 100, - current_interval_usage_count: 90, - }, - { - model_name: "MiniMax-M2.7", - current_interval_total_count: 1500, - current_interval_usage_count: 1200, - }, - ], - }), - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Plus-High-Speed (CN)") - expect(result.lines).toHaveLength(2) - expect(result.lines[0]).toMatchObject({ - label: "Session", - used: 300, - limit: 1500, - format: { kind: "count", suffix: "model-calls" }, - }) - expect(result.lines[1]).toMatchObject({ - label: "image-01", - used: 10, - limit: 100, - format: { kind: "count", suffix: "images" }, - }) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(40) + expect(result.lines[0].format.kind).toBe("percent") }) it("infers CN Plus-High-Speed from companion image-01 quota", async () => { @@ -1110,7 +1076,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Plus-High-Speed (CN)") expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(1500) + expect(result.lines[0].limit).toBe(100) }) it("infers CN Max-High-Speed from companion speech quota", async () => { @@ -1141,7 +1107,7 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Max-High-Speed (CN)") expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(4500) + expect(result.lines[0].limit).toBe(100) }) it("falls back to the coarse CN tier when companion quotas conflict", async () => { @@ -1228,9 +1194,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBe("Plus-High-Speed (CN)") - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].format.kind).toBe("percent") }) it("does not infer CN plan for unknown CN model-call limits", async () => { @@ -1259,9 +1225,9 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.plan).toBeUndefined() - expect(result.lines[0].limit).toBe(9000) - expect(result.lines[0].used).toBe(3000) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(33) + expect(result.lines[0].format.kind).toBe("percent") }) it("falls back when primary returns auth-like status", async () => { @@ -1283,8 +1249,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(120) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(40) + expect(result.lines[0].format.kind).toBe("percent") expect(ctx.host.http.request.mock.calls.length).toBe(2) }) @@ -1338,9 +1304,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(120) - expect(result.lines[0].limit).toBe(300) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(40) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("falls back to GLOBAL when MINIMAX_CN_API_KEY lookup throws in AUTO mode", async () => { @@ -1386,8 +1352,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) const line = result.lines[0] - expect(line.used).toBe(123) - expect(line.limit).toBe(500) + expect(line.used).toBe(25) + expect(line.limit).toBe(100) expect(line.resetsAt).toBe(new Date(1700000000000 + 7200000).toISOString()) expect(line.periodDurationMs).toBeUndefined() }) @@ -1478,9 +1444,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.plan).toBe("Team (GLOBAL)") - expect(result.lines[0].used).toBe(180) - expect(result.lines[0].limit).toBe(300) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(60) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("clamps negative used counts to zero", async () => { @@ -1503,7 +1469,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(0) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].format.kind).toBe("percent") }) it("clamps used counts above total", async () => { @@ -1526,7 +1492,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].used).toBe(100) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].format.kind).toBe("percent") }) it("supports epoch seconds for start/end timestamps", async () => { @@ -1681,9 +1647,9 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].used).toBe(300) - expect(result.lines[0].limit).toBe(4500) - expect(result.lines[0].format.suffix).toBe("model-calls") + expect(result.lines[0].used).toBe(7) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].format.kind).toBe("percent") }) it("classifies M2.7 as session bucket", async () => { @@ -1707,8 +1673,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].used).toBe(3000) - expect(result.lines[0].limit).toBe(15000) + expect(result.lines[0].used).toBe(20) + expect(result.lines[0].limit).toBe(100) }) it("does not classify speech-hd as session bucket", async () => { @@ -1733,7 +1699,6 @@ describe("minimax plugin", () => { const result = plugin.probe(ctx) expect(result.lines[0].label).toBe("Text to Speech HD") expect(result.lines[0].format.suffix).toBe("chars") - expect(result.lines[0].isSession).toBeUndefined() }) it("does not classify image-01 as session bucket", async () => { @@ -1779,8 +1744,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(15000) - expect(result.lines[0].used).toBe(3000) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(20) expect(result.lines[1].label).toBe("Text to Speech HD") expect(result.lines[2].label).toBe("image-01") }) @@ -1804,8 +1769,8 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].label).toBe("Session") - expect(result.lines[0].limit).toBe(1500) - expect(result.lines[0].used).toBe(300) + expect(result.lines[0].limit).toBe(100) + expect(result.lines[0].used).toBe(20) expect(result.lines[1].label).toBe("image-01") expect(result.lines[2].label).toBe("Text to Speech HD") }) @@ -1824,7 +1789,7 @@ describe("minimax plugin", () => { model_name: "MiniMax-M2.7", current_interval_total_count: 4500, current_interval_usage_count: 4200, - remains_time: 18000, // 18k seconds = 5h, matches TOKEN_PLAN_WINDOW_MS + remains_time: 18000, }, ], }), @@ -1833,8 +1798,7 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines[0].resetsAt).toBeDefined() - // remains_time=18000 is interpreted as seconds (18k*1000=18M ms), close to 5h window - expect(result.lines[0].periodDurationMs).toBeUndefined() // no end_time, so periodDurationMs not set + expect(result.lines[0].periodDurationMs).toBeUndefined() }) it("uses daily window for non-session companion buckets", async () => { @@ -1865,7 +1829,48 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - // Session bucket has no end_time so periodDurationMs is derived from remains_time expect(result.lines[1].periodDurationMs).toBe(86400000) }) -}) + + it("prefers the CN session entry when a companion bucket appears first", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "image-01", + current_interval_total_count: 100, + current_interval_usage_count: 90, + }, + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Plus-High-Speed (CN)") + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }) + expect(result.lines[1]).toMatchObject({ + label: "image-01", + used: 10, + limit: 100, + format: { kind: "count", suffix: "images" }, + }) + }) +}) \ No newline at end of file From 6a42cece67c1c43db00bae0bb9ca23a3e6ef1a06 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 10:15:04 +0100 Subject: [PATCH 10/16] docs(minimax): update checked date and fix README wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix README:38 — "coding plan session model-calls" → "token plan model-calls" (resolves Copilot review comment discussion_r3255779189) - Update quota table checked-on date to 2026-05-18 (re-verified vs official docs) refactor(minimax): simplify normalizePlanName and tighten isSessionUsageName - Merge duplicate coding/token-plan early-return into one condition - Replace broad name.includes("coding") with name.includes("coding plan") | name.includes("token plan") to avoid future false matches on companion buckets fix(minimax): const total in parseModelRemainEntry (was let) --- README.md | 2 +- docs/providers/minimax.md | 2 +- plugins/minimax/plugin.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 561b0699..68932e8b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining - [**Kiro**](docs/providers/kiro.md) / credits, bonus credits, overages - [**Kimi Code**](docs/providers/kimi.md) / session, weekly -- [**MiniMax**](docs/providers/minimax.md) / coding plan session model-calls, CN TTS/image buckets +- [**MiniMax**](docs/providers/minimax.md) / token plan model-calls, CN TTS/image buckets - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index d35a8624..9aa6050e 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -90,7 +90,7 @@ Expected payload fields: - Use `start_time` + `end_time` as `periodDurationMs` when both are valid. - Non-session companion resource lines use a daily window when only `remains_time` is present. - Prompt-based marketing copy is ignored by the plugin; all inference is based on raw remains quotas and companion resource buckets. -- Official package tables used for this split, checked on 2026-03-23: +- Official package tables used for this split, checked on 2026-05-18: - Global: - CN: diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index b91e2811..d34dc106 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -77,8 +77,7 @@ const compact = raw.replace(/\s+/g, " ").trim() const withoutPrefix = compact.replace(/^minimax\s+coding\s+plan\b[:\-]?\s*/i, "").trim() const base = withoutPrefix || compact - if (/coding\s+plan/i.test(compact) && !withoutPrefix) return "Token Plan" - if (/token\s+plan/i.test(compact) && !withoutPrefix) return "Token Plan" + if (!withoutPrefix && /(?:coding|token)\s+plan/i.test(compact)) return "Token Plan" const canonical = base .replace(/\s*-\s*/g, "-") @@ -147,7 +146,8 @@ return ( name.includes("minimax-m") || name.includes("text model") || - name.includes("coding") || + name.includes("coding plan") || + name.includes("token plan") || name.includes("m2.7") || name.includes("minimax_m") ) @@ -395,7 +395,7 @@ if (!item || typeof item !== "object") return null const usageMeta = classifyUsageEntry(item, index) - let total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) + const total = readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) if (total === null || total <= 0) return null const usageFieldCount = readNumber(item.current_interval_usage_count ?? item.currentIntervalUsageCount) From af3b7ca85e1a9235a24cb925c085939edf00de86 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 10:30:56 +0100 Subject: [PATCH 11/16] fix(minimax): prevent 'Speech 2.8 Turbo' from being classified as HD isSpeechHdUsageName matched anything containing "speech 2.8", so a payload named "Speech 2.8 Turbo" (space-separated) would short-circuit to HD before the turbo matcher ran. Its quota then polluted speechHdTotal and could flip plan disambiguation (e.g. Plus -> Plus-High-Speed). - Guard isSpeechHdUsageName with an early-return when name contains "turbo" - Extend isSpeechTurboUsageName to also match space-separated form ("speech 2.8 turbo") in addition to the existing hyphen form - Add regression test asserting label + plan tier remain correct --- plugins/minimax/plugin.js | 2 ++ plugins/minimax/plugin.test.js | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index d34dc106..68357d95 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -124,6 +124,7 @@ } function isSpeechHdUsageName(name) { + if (name.includes("turbo")) return false return ( name.includes("text to speech hd") || name.includes("speech 2.8") || @@ -134,6 +135,7 @@ function isSpeechTurboUsageName(name) { return ( name.includes("text to speech turbo") || + /\bspeech[\s\-][\d.]+[\s\-]turbo\b/.test(name) || /^speech(?:-[\d.]+)?-turbo$/.test(name) ) } diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 1dac497a..4ca0d53b 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1167,6 +1167,47 @@ describe("minimax plugin", () => { }) }) + it("does not classify space-separated 'Speech 2.8 Turbo' as HD", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 1500, + current_interval_usage_count: 1400, + }, + { + model_name: "Speech 2.8 Turbo", + current_interval_total_count: 9000, + current_interval_usage_count: 9000, + }, + { + model_name: "image-01", + current_interval_total_count: 50, + current_interval_usage_count: 50, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + // Turbo entry must be labelled Turbo, not HD + const turboLine = result.lines.find((line) => line.label === "Text to Speech Turbo") + expect(turboLine).toBeDefined() + expect(result.lines.find((line) => line.label === "Text to Speech HD")).toBeUndefined() + + // Turbo quota (9000) must not pollute speech-hd disambiguation; + // image-01 50 alone keeps the plan at Plus (CN), not Plus-High-Speed. + expect(result.plan).toBe("Plus (CN)") + }) + it("normalizes CN explicit high-speed plan labels to the shared six-plan naming", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) From fbda6a44b7d42cbf22cc21822aa6665bb46560fb Mon Sep 17 00:00:00 2001 From: "Frankie F.-C. WANG" Date: Mon, 18 May 2026 11:38:45 +0100 Subject: [PATCH 12/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/providers/minimax.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 9aa6050e..38cc6d68 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -99,9 +99,9 @@ Expected payload fields: - **Plan**: best-effort from API payload (normalized to concise label, with ` (CN)` or ` (GLOBAL)` suffix) - **Session** (overview progress line): - `label`: `Session` - - `format`: count (`model-calls`) - - `used`: computed used model-call count from raw remains data - - `limit`: raw session limit from the remains payload + - `format`: percent + - `used`: computed session usage percentage (`0`-`100`) derived from raw remains data + - `limit`: `100` - `resetsAt`: derived from `end_time` or `remains_time` - **Extra resources** (detail progress lines when present in either region): - `Text to Speech HD` / `Text to Speech Turbo`: count (`chars`) From 2ac1c841853bdf2c8e87ab63a16932729a30d982 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 13:56:54 +0100 Subject: [PATCH 13/16] docs(minimax): extend percent semantics fix to Overview and Usage Mapping Copilot autofix (fbda6a4) updated the Output contract to percent/limit:100 but left two earlier sections still describing Session as raw model-calls: - Overview "Display note": now describes Session as percentage + companion buckets as raw counts (chars/images). - Usage Mapping bullet: same alignment, clarifying that prompts conversion is intentionally skipped. --- docs/providers/minimax.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 38cc6d68..4a90937b 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -8,7 +8,7 @@ - **Endpoint:** `GET https://api.minimax.io/v1/api/openplatform/coding_plan/remains` - **Auth:** `Authorization: Bearer ` - **Window model:** dynamic rolling 5-hour limit (per MiniMax Coding Plan docs) -- **Display note:** OpenUsage shows the raw text-session counts from the remains API as `model-calls`, because that matches the observed official usage display. +- **Display note:** OpenUsage renders the main `Session` line as a percentage (`0`-`100`) so it visually aligns with other providers (claude/codex), and renders companion resource buckets (TTS HD/Turbo, `image-01`, etc.) as raw counts (`chars`/`images`). - **CN note:** current CN docs use `https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains`. ## Authentication @@ -65,7 +65,7 @@ Expected payload fields: ## Usage Mapping - Treat `current_interval_usage_count` as the remaining raw session/resource count returned by the remains API. -- For the main text `Session` line, OpenUsage displays the raw remains numbers as `model-calls` rather than converting them to `prompts`. +- For the main text `Session` line, OpenUsage emits a percentage (`0`-`100`) computed from the raw remains numbers (no prompt conversion). Companion resource buckets keep their raw counts (`chars` / `images`). - If only remaining aliases are provided, compute `used = total - remaining`. - If explicit used-count fields are provided, prefer them. - Plan name is taken from explicit plan/title fields when available, and normalized to a shared six-plan naming scheme: From 67f6e2b42d9a3539641048b7d71260ddcddd3f3f Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 18 May 2026 17:12:42 +0100 Subject: [PATCH 14/16] fix(minimax): tighten session model matchers to avoid music/multimodal false matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit name.includes("minimax-m") would also match future non-text model names like "MiniMax-Music-2.6" or "MiniMax-Multimodal", which would then be misclassified as session — wrong label (Session vs Music), wrong window (5h vs daily, per both official Token Plan docs), and wrong format (percent vs count). Tighten the M-series patterns to require a digit after the M: - name.includes("minimax-m") -> /minimax-m\d/ - name.includes("minimax_m") -> /minimax_m\d/ Existing "MiniMax-M*" wildcard tests still pass via the `index === 0` and `pickSessionRemainItem` fallback paths. Add a regression test asserting "MiniMax-Music-2.6" is rendered as its own non-session line. --- plugins/minimax/plugin.js | 4 ++-- plugins/minimax/plugin.test.js | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 68357d95..e10f4a3f 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -146,12 +146,12 @@ function isSessionUsageName(name) { return ( - name.includes("minimax-m") || + /minimax-m\d/.test(name) || name.includes("text model") || name.includes("coding plan") || name.includes("token plan") || name.includes("m2.7") || - name.includes("minimax_m") + /minimax_m\d/.test(name) ) } diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 4ca0d53b..4e9b3c8b 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1167,6 +1167,45 @@ describe("minimax plugin", () => { }) }) + it("does not classify MiniMax-Music or MiniMax-Multimodal as Session", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + current_interval_total_count: 4500, + current_interval_usage_count: 4200, + }, + { + model_name: "MiniMax-Music-2.6", + current_interval_total_count: 100, + current_interval_usage_count: 100, + remains_time: 3600, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + // First line is the real session (M2.7), as percent + expect(result.lines[0].label).toBe("Session") + expect(result.lines[0].format.kind).toBe("percent") + + // Music line keeps its raw name and is NOT labelled Session + const musicLine = result.lines.find((line) => line.label === "MiniMax-Music-2.6") + expect(musicLine).toBeDefined() + expect(musicLine.format.kind).toBe("count") + // Music quota total (100) must not have polluted the M2.7 session bucket pick + expect(result.lines.filter((line) => line.label === "Session")).toHaveLength(1) + }) + it("does not classify space-separated 'Speech 2.8 Turbo' as HD", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) From 7a066fcadee735c355b3f7108e42d0f18a577d6f Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Sun, 24 May 2026 01:04:48 +0100 Subject: [PATCH 15/16] Keep MiniMax VLM and search quotas under session MiniMax can return coding-plan-vlm and coding-plan-search in arbitrary model_remains order. Keep the primary Session line first, then promote only those two coding-plan buckets directly below it while preserving all other resource rows in API order. Constraint: User requested only Session, coding-plan-vlm, and coding-plan-search ordering; other rows must not be globally sorted. Rejected: Sort all companion resources | would reorder unrelated TTS/image rows the user explicitly said not to manage. Confidence: high Scope-risk: narrow Directive: Do not expand this ordering helper to unrelated MiniMax resources without a specific display requirement. Tested: npm test -- plugins/minimax/plugin.test.js --- plugins/minimax/plugin.js | 21 ++++++++++++-- plugins/minimax/plugin.test.js | 53 +++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index e10f4a3f..f30a5d9b 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -482,6 +482,13 @@ return fallbackItem } + function getCompanionResourceOrder(item) { + const name = normalizeUsageNameKey(readUsageRawName(item)) + if (name === "coding-plan-vlm" || name === "coding_plan_vlm") return 1 + if (name === "coding-plan-search" || name === "coding_plan_search") return 2 + return null + } + function orderRemainItemsForDisplay(modelRemains, endpointSelection) { if (!Array.isArray(modelRemains) || modelRemains.length === 0) return [] @@ -489,14 +496,24 @@ const sessionItem = pickSessionRemainItem(modelRemains) if (sessionItem) ordered.push(sessionItem) + const companionItems = [] + const otherItems = [] + for (let i = 0; i < modelRemains.length; i += 1) { const item = modelRemains[i] if (!item || typeof item !== "object") continue if (sessionItem && item === sessionItem) continue - ordered.push(item) + + const companionOrder = getCompanionResourceOrder(item) + if (companionOrder !== null) { + companionItems.push({ item, order: companionOrder, index: i }) + } else { + otherItems.push(item) + } } - return ordered + companionItems.sort((a, b) => a.order - b.order || a.index - b.index) + return ordered.concat(companionItems.map((entry) => entry.item), otherItems) } function parsePayloadShape(ctx, payload, endpointSelection) { diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index 4e9b3c8b..9a456a5d 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1153,18 +1153,13 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Plus (CN)") expect(result.lines).toHaveLength(5) - expect(result.lines[1]).toMatchObject({ - label: "Text to Speech HD", - format: { kind: "count", suffix: "chars" }, - }) - expect(result.lines[3]).toMatchObject({ - label: "Text to Speech Turbo", - format: { kind: "count", suffix: "chars" }, - }) - expect(result.lines[4]).toMatchObject({ - label: "Image Generation", - format: { kind: "count", suffix: "images" }, - }) + expect(result.lines.map((line) => line.label)).toEqual([ + "Session", + "Text to Speech HD", + "image-01", + "Text to Speech Turbo", + "Image Generation", + ]) }) it("does not classify MiniMax-Music or MiniMax-Multimodal as Session", async () => { @@ -1855,6 +1850,38 @@ describe("minimax plugin", () => { expect(result.lines[2].label).toBe("Text to Speech HD") }) + it("keeps coding-plan-vlm and coding-plan-search directly under Session", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + { model_name: "MiniMax-Music-2.6", current_interval_total_count: 1000, current_interval_usage_count: 500 }, + { model_name: "image-01", current_interval_total_count: 100, current_interval_usage_count: 80 }, + { model_name: "coding-plan-search", current_interval_total_count: 200, current_interval_usage_count: 150 }, + { model_name: "MiniMax-M2.7", current_interval_total_count: 15000, current_interval_usage_count: 12000 }, + { model_name: "speech-hd", current_interval_total_count: 11000, current_interval_usage_count: 7200 }, + { model_name: "coding-plan-vlm", current_interval_total_count: 300, current_interval_usage_count: 240 }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.map((line) => line.label)).toEqual([ + "Session", + "coding-plan-vlm", + "coding-plan-search", + "MiniMax-Music-2.6", + "image-01", + "Text to Speech HD", + ]) + }) + it("uses 5h token-plan window for session bucket remains_time inference", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) @@ -1953,4 +1980,4 @@ describe("minimax plugin", () => { format: { kind: "count", suffix: "images" }, }) }) -}) \ No newline at end of file +}) From 1211275bef179425b7fccf772395024d04a75671 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Wed, 27 May 2026 16:20:10 +0100 Subject: [PATCH 16/16] refactor(minimax): drop dead MODEL_CALLS_SUFFIX and unused endpointSelection param Session lines render as percent, so the suffix constant was never read. parseModelRemainEntry and orderRemainItemsForDisplay never referenced endpointSelection; remove it from their signatures and call sites. --- plugins/minimax/plugin.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index f30a5d9b..b0da3af8 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -46,7 +46,6 @@ speechHd: { 11000: "Max", 19000: "Max-High-Speed" }, }, } - const MODEL_CALLS_SUFFIX = "model-calls" function readString(value) { if (typeof value !== "string") return null @@ -244,10 +243,10 @@ return { label: "Image Generation", suffix: "images", isSession: false } } if (isSessionUsageName(name)) { - return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } + return { label: "Session", suffix: "count", isSession: true } } if (index === 0) { - return { label: "Session", suffix: MODEL_CALLS_SUFFIX, isSession: true } + return { label: "Session", suffix: "count", isSession: true } } return { label: rawName || "Usage", @@ -393,7 +392,7 @@ throw "Could not parse usage data." } - function parseModelRemainEntry(ctx, item, endpointSelection, index) { + function parseModelRemainEntry(ctx, item, index) { if (!item || typeof item !== "object") return null const usageMeta = classifyUsageEntry(item, index) @@ -489,7 +488,7 @@ return null } - function orderRemainItemsForDisplay(modelRemains, endpointSelection) { + function orderRemainItemsForDisplay(modelRemains) { if (!Array.isArray(modelRemains) || modelRemains.length === 0) return [] const ordered = [] @@ -550,10 +549,10 @@ const entries = [] const seenLabels = Object.create(null) - const remainsToParse = orderRemainItemsForDisplay(modelRemains, endpointSelection) + const remainsToParse = orderRemainItemsForDisplay(modelRemains) for (let i = 0; i < remainsToParse.length; i += 1) { - const entry = parseModelRemainEntry(ctx, remainsToParse[i], endpointSelection, i) + const entry = parseModelRemainEntry(ctx, remainsToParse[i], i) if (!entry) continue if (seenLabels[entry.label]) continue seenLabels[entry.label] = true