From d31725835a7dbd8739469a47654670a9dc12a88c Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 1 Jun 2026 23:09:34 +0100 Subject: [PATCH 1/3] feat(minimax): rebuild plugin for credit-based Token Plan schema The Token Plan moved from model-call-count tiers to credit/token-based plans (Plus/Max/Ultra). The remains API now returns current_interval_total_count as 0 and instead exposes current_interval_remaining_percent plus a second weekly window (current_weekly_*) per model_name bucket (general, video). - Query the officially documented token_plan/remains endpoint; keep coding_plan/remains as a legacy fallback (identical payload) - Drive every line from *_remaining_percent (used = 100 - remaining_percent) - Render both the 5h interval and weekly windows for each bucket - Map general -> Session/Weekly, video -> Video/Video (Weekly), title-case any other bucket; all lines are percent format - Show Session + Weekly on the overview (matching claude/codex); Video on detail - Plan name priority: explicit API field -> count->tier fallback -> MINIMAX_PLAN override -> generic 'Token Plan' baseline (never blank). The credit-based remains API exposes no tier, so MINIMAX_PLAN lets users pin Plus/Max/Ultra. - Drop the dead companion-resource classifiers (speech HD/Turbo, image-01) and quota-hint disambiguation; those buckets no longer exist in the response --- plugins/minimax/plugin.js | 337 ++++++++++++++++++++++++------------ plugins/minimax/plugin.json | 5 +- 2 files changed, 227 insertions(+), 115 deletions(-) diff --git a/plugins/minimax/plugin.js b/plugins/minimax/plugin.js index 2e10476c..035e58ad 100644 --- a/plugins/minimax/plugin.js +++ b/plugins/minimax/plugin.js @@ -1,30 +1,47 @@ (function () { - const GLOBAL_PRIMARY_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" + // token_plan/remains is the officially documented Token Plan usage endpoint. + // coding_plan/remains is retained as a legacy fallback for older accounts/regions. + const GLOBAL_PRIMARY_USAGE_URL = "https://api.minimax.io/v1/token_plan/remains" const GLOBAL_FALLBACK_USAGE_URLS = [ - "https://api.minimax.io/v1/coding_plan/remains", - "https://www.minimax.io/v1/api/openplatform/coding_plan/remains", + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ] + const CN_PRIMARY_USAGE_URL = "https://api.minimaxi.com/v1/token_plan/remains" + const CN_FALLBACK_USAGE_URLS = [ + "https://www.minimaxi.com/v1/token_plan/remains", + "https://www.minimaxi.com/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 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 = { - 100: "Starter", - 300: "Plus", - 1000: "Max", - 2000: "Ultra", + // Optional manual tier pin. The credit-based remains API exposes no plan/tier + // field, so users can set this to surface their tier (e.g. "Plus"/"Max"/"Ultra"). + const PLAN_OVERRIDE_ENV_VARS = ["MINIMAX_PLAN", "MINIMAX_CODING_PLAN"] + const DEFAULT_PLAN_NAME = "Token Plan" + const INTERVAL_WINDOW_MS = 5 * 60 * 60 * 1000 + const WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + const WINDOW_TOLERANCE_MS = 10 * 60 * 1000 + + // Token Plan tiers are credit/token based; the remains API no longer exposes + // per-tier model-call counts (current_interval_total_count is 0). These tables + // remain as a best-effort fallback for any account that still reports a tier + // quota as a raw model-call total. They will not resolve for credit-based plans. + const GLOBAL_MODEL_CALL_LIMIT_TO_PLAN = { + 1500: "Starter", + 4500: "Plus", + 15000: "Max", + 30000: "Ultra", } - // 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 = { + const CN_MODEL_CALL_LIMIT_TO_PLAN = { 600: "Starter", 1500: "Plus", 4500: "Max", + 30000: "Ultra", + } + + // model_name -> overview/detail line labels for the 5h interval and weekly windows. + const KNOWN_MODEL_LABELS = { + general: { interval: "Session", weekly: "Weekly" }, } - const MODEL_CALLS_PER_PROMPT = 15 function readString(value) { if (typeof value !== "string") return null @@ -49,14 +66,46 @@ return null } + function clampPercent(value) { + if (value < 0) return 0 + if (value > 100) return 100 + return value + } + + function titleCaseModelName(value) { + const raw = readString(value) + if (!raw) return null + return raw + .replace(/[_]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/(^|[\s-])([a-z])/g, (_match, boundary, letter) => boundary + letter.toUpperCase()) + } + function normalizePlanName(value) { const raw = readString(value) 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 (!withoutPrefix && /(?:coding|token)\s+plan/i.test(compact)) return "Token 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 (/^ultra$/i.test(canonical)) return "Ultra" + 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 +114,9 @@ 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 + return CN_MODEL_CALL_LIMIT_TO_PLAN[normalized] || null } - - if (GLOBAL_PROMPT_LIMIT_TO_PLAN[normalized]) return GLOBAL_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 GLOBAL_MODEL_CALL_LIMIT_TO_PLAN[normalized] || null } function epochToMs(epoch) { @@ -82,7 +125,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 @@ -98,8 +141,8 @@ } } - // Coding Plan resets every 5h. Use that constraint before defaulting. - const maxExpectedMs = CODING_PLAN_WINDOW_MS + CODING_PLAN_WINDOW_TOLERANCE_MS + // Use expectedWindowMs constraint before defaulting. + const maxExpectedMs = (expectedWindowMs || INTERVAL_WINDOW_MS) + WINDOW_TOLERANCE_MS const secondsLooksValid = asSecondsMs <= maxExpectedMs const millisecondsLooksValid = asMillisecondsMs <= maxExpectedMs @@ -112,6 +155,95 @@ return secOverflow <= msOverflow ? asSecondsMs : asMillisecondsMs } + // Each model_remains entry now reports two enforced windows: a rolling 5-hour + // interval and a weekly window. Both expose a remaining-percent field directly. + const WINDOWS = [ + { + key: "interval", + percentField: "current_interval_remaining_percent", + totalField: "current_interval_total_count", + usageField: "current_interval_usage_count", + startField: "start_time", + endField: "end_time", + remainsField: "remains_time", + expectedWindowMs: INTERVAL_WINDOW_MS, + }, + { + key: "weekly", + percentField: "current_weekly_remaining_percent", + totalField: "current_weekly_total_count", + usageField: "current_weekly_usage_count", + startField: "weekly_start_time", + endField: "weekly_end_time", + remainsField: "weekly_remains_time", + expectedWindowMs: WEEKLY_WINDOW_MS, + }, + ] + + function readModelName(item) { + return readString(item.model_name) || readString(item.modelName) + } + + function windowLabel(item, windowKey) { + const modelName = (readModelName(item) || "").toLowerCase() + const known = KNOWN_MODEL_LABELS[modelName] + if (known) return known[windowKey] + + const display = titleCaseModelName(readModelName(item)) || "Usage" + return windowKey === "weekly" ? display + " (Weekly)" : display + } + + // Returns a 0-100 used percentage for the window, or null when the window + // carries no usable data. Prefers the API-provided remaining-percent field; + // falls back to count math (usage_count is the remaining count) when absent. + function computeWindowUsedPercent(item, win) { + const remainingPercent = readNumber(item[win.percentField]) + if (remainingPercent !== null) { + return clampPercent(Math.round(100 - remainingPercent)) + } + + const total = readNumber(item[win.totalField]) + if (total === null || total <= 0) return null + const remaining = readNumber(item[win.usageField]) + if (remaining === null) return null + return clampPercent(Math.round(((total - remaining) / total) * 100)) + } + + function computeWindowTiming(ctx, item, win, nowMs) { + const startMs = epochToMs(item[win.startField]) + const endMs = epochToMs(item[win.endField]) + const remainsRaw = readNumber(item[win.remainsField]) + const remainsMs = inferRemainsMs(remainsRaw, endMs, nowMs, win.expectedWindowMs) + + 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 + + return { resetsAt, periodDurationMs } + } + + function parseModelRemainEntries(ctx, item, nowMs) { + if (!item || typeof item !== "object") return [] + + const entries = [] + for (let i = 0; i < WINDOWS.length; i += 1) { + const win = WINDOWS[i] + const usedPercent = computeWindowUsedPercent(item, win) + if (usedPercent === null) continue + + const timing = computeWindowTiming(ctx, item, win, nowMs) + entries.push({ + label: windowLabel(item, win.key), + used: usedPercent, + resetsAt: timing.resetsAt, + periodDurationMs: timing.periodDurationMs, + }) + } + return entries + } + function loadApiKey(ctx, endpointSelection) { const envVars = endpointSelection === "CN" ? CN_API_KEY_ENV_VARS : GLOBAL_API_KEY_ENV_VARS for (let i = 0; i < envVars.length; i += 1) { @@ -131,6 +263,21 @@ return null } + function readPlanOverride(ctx) { + for (let i = 0; i < PLAN_OVERRIDE_ENV_VARS.length; i += 1) { + const name = PLAN_OVERRIDE_ENV_VARS[i] + let value = null + try { + value = ctx.host.env.get(name) + } catch (e) { + ctx.host.log.warn("env read failed for " + name + ": " + String(e)) + } + const plan = readString(value) + if (plan) return plan + } + return null + } + function getUsageUrls(endpointSelection) { if (endpointSelection === "CN") { return [CN_PRIMARY_USAGE_URL].concat(CN_FALLBACK_USAGE_URLS) @@ -212,6 +359,16 @@ throw "Could not parse usage data." } + function readGeneralIntervalTotal(modelRemains) { + for (let i = 0; i < modelRemains.length; i += 1) { + const item = modelRemains[i] + if (!item || typeof item !== "object") continue + if ((readModelName(item) || "").toLowerCase() !== "general") continue + return readNumber(item.current_interval_total_count ?? item.currentIntervalTotalCount) + } + return null + } + function parsePayloadShape(ctx, payload, endpointSelection) { if (!payload || typeof payload !== "object") return null @@ -244,69 +401,21 @@ if (!modelRemains || modelRemains.length === 0) return null - let chosen = modelRemains[0] - 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 - } - } - - 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) + const entries = [] + const seenLabels = Object.create(null) - let resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null - if (!resetsAt && remainsMs !== null) { - resetsAt = ctx.util.toIso(nowMs + remainsMs) + for (let i = 0; i < modelRemains.length; i += 1) { + const itemEntries = parseModelRemainEntries(ctx, modelRemains[i], nowMs) + for (let j = 0; j < itemEntries.length; j += 1) { + const entry = itemEntries[j] + if (seenLabels[entry.label]) continue + seenLabels[entry.label] = true + entries.push(entry) + } } - 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 +427,15 @@ payload.plan_name, payload.plan, ])) - const inferredPlanName = inferPlanNameFromLimit(total, endpointSelection) + const inferredPlanName = inferPlanNameFromLimit( + readGeneralIntervalTotal(modelRemains), + endpointSelection + ) const planName = explicitPlanName || inferredPlanName return { planName, - used, - total, - resetsAt, - periodDurationMs, + entries, } } @@ -362,25 +471,25 @@ 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 result = { lines: [ctx.line.progress(line)] } - if (parsed.planName) { - const regionLabel = successfulEndpoint === "CN" ? " (CN)" : " (GLOBAL)" - result.plan = parsed.planName + regionLabel - } + const lines = parsed.entries.map((entry) => { + const line = { + label: entry.label, + used: entry.used, + limit: 100, + format: { kind: "percent" }, + } + if (entry.resetsAt) line.resetsAt = entry.resetsAt + if (entry.periodDurationMs !== null) line.periodDurationMs = entry.periodDurationMs + return ctx.line.progress(line) + }) + + // Plan-name priority: explicit API field / count-inference (parsed.planName) + // -> manual MINIMAX_PLAN override -> generic baseline so the line is never blank. + const planName = + parsed.planName || normalizePlanName(readPlanOverride(ctx)) || DEFAULT_PLAN_NAME + const regionLabel = successfulEndpoint === "CN" ? " (CN)" : " (GLOBAL)" + + const result = { lines, plan: planName + regionLabel } return result } diff --git a/plugins/minimax/plugin.json b/plugins/minimax/plugin.json index f8a714aa..149dafd4 100644 --- a/plugins/minimax/plugin.json +++ b/plugins/minimax/plugin.json @@ -7,6 +7,9 @@ "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": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Video", "scope": "detail" }, + { "type": "progress", "label": "Video (Weekly)", "scope": "detail" } ] } From 0f661d0402d11b126c5fdde0cdec32fabf2de254 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 1 Jun 2026 23:09:34 +0100 Subject: [PATCH 2/3] test(minimax): rewrite suite for dual-window percent schema Replace the model-call-count/companion-bucket fixtures with the live remains shape (general + video buckets, interval + weekly remaining percents). Cover the token_plan/remains endpoints with legacy fallback, percent-driven Session/Weekly/Video lines, count-based tier-inference fallback, the MINIMAX_PLAN override and generic baseline, reset/period derivation, and the existing endpoint/auth/error paths. --- plugins/minimax/plugin.test.js | 780 +++++++++++++-------------------- 1 file changed, 295 insertions(+), 485 deletions(-) diff --git a/plugins/minimax/plugin.test.js b/plugins/minimax/plugin.test.js index fa1112a1..bfe41625 100644 --- a/plugins/minimax/plugin.test.js +++ b/plugins/minimax/plugin.test.js @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" 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 PRIMARY_USAGE_URL = "https://api.minimax.io/v1/token_plan/remains" +const FALLBACK_USAGE_URL = "https://www.minimax.io/v1/token_plan/remains" +const LEGACY_WWW_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" +const CN_PRIMARY_USAGE_URL = "https://api.minimaxi.com/v1/token_plan/remains" +const CN_FALLBACK_USAGE_URL = "https://www.minimaxi.com/v1/token_plan/remains" +const CN_LEGACY_FALLBACK_USAGE_URL = "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains" const loadPlugin = async () => { await import("./plugin.js") @@ -18,19 +19,53 @@ function setEnv(ctx, envValues) { ) } +// Models the live Token Plan remains response: a "general" bucket carrying both a +// rolling 5-hour interval and a weekly window, each with a remaining-percent field. +function generalBucket(overrides) { + return Object.assign( + { + model_name: "general", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + start_time: 1700000000000, + end_time: 1700018000000, + remains_time: 15994987, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + weekly_start_time: 1700000000000, + weekly_end_time: 1700604800000, + weekly_remains_time: 498394987, + }, + overrides || {} + ) +} + +function videoBucket(overrides) { + return Object.assign( + { + model_name: "video", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + start_time: 1700000000000, + end_time: 1700086400000, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + weekly_start_time: 1700000000000, + weekly_end_time: 1700604800000, + }, + overrides || {} + ) +} + function successPayload(overrides) { const base = { base_resp: { status_code: 0 }, plan_name: "Plus", - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 300, - current_interval_usage_count: 180, - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], + model_remains: [generalBucket()], } if (!overrides) return base return Object.assign(base, overrides) @@ -111,46 +146,6 @@ describe("minimax plugin", () => { expect(result.plan).toBe("Plus (CN)") }) - it("prefers MINIMAX_CN_API_KEY in AUTO mode when both keys exist", async () => { - const ctx = makeCtx() - setEnv(ctx, { - MINIMAX_CN_API_KEY: "cn-key", - MINIMAX_API_KEY: "global-key", - }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify(successPayload()), - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - const call = ctx.host.http.request.mock.calls[0][0] - expect(call.url).toBe(CN_PRIMARY_USAGE_URL) - expect(call.headers.Authorization).toBe("Bearer cn-key") - expect(result.plan).toBe("Plus (CN)") - }) - - it("uses MINIMAX_API_KEY when CN key is missing", async () => { - const ctx = makeCtx() - setEnv(ctx, { - MINIMAX_API_KEY: "global-key", - }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify(successPayload()), - }) - - const plugin = await loadPlugin() - plugin.probe(ctx) - - const call = ctx.host.http.request.mock.calls[0][0] - expect(call.url).toBe(PRIMARY_USAGE_URL) - expect(call.headers.Authorization).toBe("Bearer global-key") - }) - it("uses GLOBAL first in AUTO mode when CN key is missing", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "global-key" }) @@ -178,17 +173,7 @@ describe("minimax plugin", () => { return { status: 200, headers: {}, - bodyText: JSON.stringify(successPayload({ - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 1500, // CN Plus: 100 prompts × 15 - current_interval_usage_count: 1200, // Remaining - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - })), + bodyText: JSON.stringify(successPayload()), } } return { status: 404, headers: {}, bodyText: "{}" } @@ -197,7 +182,6 @@ 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)") 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 @@ -214,6 +198,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 +215,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: "{}" } }) @@ -237,45 +223,64 @@ describe("minimax plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired. Check your MiniMax API key.") }) - it("parses usage, plan, reset timestamp, and period duration", async () => { + it("renders Session + Weekly from the general bucket remaining percentages", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, - bodyText: JSON.stringify(successPayload()), + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + plan_name: "Max", + model_remains: [ + generalBucket({ + current_interval_remaining_percent: 60, + current_weekly_remaining_percent: 85, + }), + ], + }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Plus (GLOBAL)") - expect(result.lines.length).toBe(1) - 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.limit).toBe(300) - expect(line.format.kind).toBe("count") - expect(line.format.suffix).toBe("prompts") - expect(line.resetsAt).toBe("2023-11-15T03:13:20.000Z") - expect(line.periodDurationMs).toBe(18000000) + expect(result.plan).toBe("Max (GLOBAL)") + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 40, + limit: 100, + format: { kind: "percent" }, + resetsAt: "2023-11-15T03:13:20.000Z", + periodDurationMs: 18000000, + }) + expect(result.lines[1]).toMatchObject({ + label: "Weekly", + used: 15, + limit: 100, + format: { kind: "percent" }, + resetsAt: new Date(1700604800000).toISOString(), + periodDurationMs: 604800000, + }) }) - it("treats current_interval_usage_count as remaining prompts", async () => { + it("renders Video interval and weekly lines after the general bucket", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + 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: [ - { - current_interval_total_count: 1500, - current_interval_usage_count: 1500, - remains_time: 3600000, - }, + generalBucket({ + current_interval_remaining_percent: 70, + current_weekly_remaining_percent: 90, + }), + videoBucket({ + current_interval_remaining_percent: 100, + current_weekly_remaining_percent: 100, + }), ], }), }) @@ -283,23 +288,32 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(0) - expect(result.lines[0].limit).toBe(1500) + expect(result.lines.map((line) => line.label)).toEqual([ + "Session", + "Weekly", + "Video", + "Video (Weekly)", + ]) + expect(result.lines[0]).toMatchObject({ label: "Session", used: 30, limit: 100 }) + expect(result.lines[2]).toMatchObject({ label: "Video", used: 0, format: { kind: "percent" } }) + expect(result.lines[3]).toMatchObject({ label: "Video (Weekly)", used: 0 }) }) - it("infers Starter plan from 1500 model-call limit", async () => { + it("title-cases unknown model_name buckets for their lines", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + 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: [ + generalBucket({ current_interval_remaining_percent: 80 }), { - current_interval_total_count: 1500, - current_interval_usage_count: 1200, - model_name: "MiniMax-M2", + model_name: "music_generation", + current_interval_remaining_percent: 50, + start_time: 1700000000000, + end_time: 1700018000000, }, ], }), @@ -308,100 +322,69 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() 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) + const musicLine = result.lines.find((line) => line.label === "Music Generation") + expect(musicLine).toBeDefined() + expect(musicLine.used).toBe(50) + expect(musicLine.format.kind).toBe("percent") }) - it("does not fallback to model name when plan cannot be inferred", async () => { + it("falls back to a generic Token Plan label when no tier can be determined", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + 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: [ - { - current_interval_total_count: 1337, - current_interval_usage_count: 1000, - model_name: "MiniMax-M2.5", - }, - ], + model_remains: [generalBucket({ current_interval_remaining_percent: 25 })], }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBeUndefined() - expect(result.lines[0].used).toBe(337) + expect(result.plan).toBe("Token Plan (CN)") + expect(result.lines[0]).toMatchObject({ label: "Session", used: 75 }) }) - it("supports nested payload and remains_time reset fallback", async () => { + it("surfaces the MINIMAX_PLAN override when the API exposes no tier", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) - vi.spyOn(Date, "now").mockReturnValue(1700000000000) + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key", MINIMAX_PLAN: "plus" }) ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: JSON.stringify({ - data: { - base_resp: { status_code: 0 }, - current_subscribe_title: "Max", - model_remains: [ - { - current_interval_total_count: 100, - current_interval_usage_count: 40, - remains_time: 7200, - }, - ], - }, + base_resp: { status_code: 0 }, + model_remains: [generalBucket({ current_interval_remaining_percent: 25 })], }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - const line = result.lines[0] - const expectedReset = new Date(1700000000000 + 7200 * 1000).toISOString() - expect(result.plan).toBe("Max (GLOBAL)") - expect(line.used).toBe(60) - expect(line.limit).toBe(100) - expect(line.resetsAt).toBe(expectedReset) + expect(result.plan).toBe("Plus (CN)") }) - it("treats small remains_time values as milliseconds when seconds exceed window", async () => { + it("prefers an explicit API plan field over the MINIMAX_PLAN override", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) - vi.spyOn(Date, "now").mockReturnValue(1700000000000) + setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key", MINIMAX_PLAN: "ultra" }) ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: JSON.stringify({ - data: { - base_resp: { status_code: 0 }, - model_remains: [ - { - current_interval_total_count: 100, - current_interval_usage_count: 55, - remains_time: 300000, - }, - ], - }, + base_resp: { status_code: 0 }, + plan_name: "Max", + model_remains: [generalBucket({ current_interval_remaining_percent: 25 })], }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - const line = result.lines[0] - expect(line.used).toBe(45) - expect(line.limit).toBe(100) - expect(line.resetsAt).toBe(new Date(1700000000000 + 300000).toISOString()) + expect(result.plan).toBe("Max (CN)") }) - it("supports remaining-count payload variants", async () => { + it("falls back to count math and tier inference when percent is absent", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ @@ -409,11 +392,12 @@ describe("minimax plugin", () => { headers: {}, bodyText: JSON.stringify({ base_resp: { status_code: 0 }, - plan_name: "MiniMax Coding Plan Pro", model_remains: [ { - current_interval_total_count: 300, - current_interval_remaining_count: 120, + model_name: "general", + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + start_time: 1700000000000, end_time: 1700018000000, }, ], @@ -422,202 +406,182 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - const line = result.lines[0] - expect(result.plan).toBe("Pro (GLOBAL)") - expect(line.used).toBe(180) - expect(line.limit).toBe(300) + // 1500 maps to Starter on GLOBAL; usage_count is the remaining count. + expect(result.plan).toBe("Starter (GLOBAL)") + expect(result.lines).toHaveLength(1) + expect(result.lines[0]).toMatchObject({ + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }) }) - it("throws on HTTP auth status", async () => { + it("normalizes explicit Ultra plan titles", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) - ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }) + 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: "Ultra", + model_remains: [generalBucket({ current_interval_remaining_percent: 40 })], + }, + }), + }) + const plugin = await loadPlugin() - let message = "" - try { - plugin.probe(ctx) - } catch (e) { - message = String(e) - } - expect(message).toContain("Session expired") - expect(ctx.host.http.request.mock.calls.length).toBe(5) + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra (CN)") + expect(result.lines[0]).toMatchObject({ label: "Session", used: 60 }) }) - it("falls back to secondary endpoint when primary fails", 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.mockImplementation((req) => { - if (req.url === PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" } - if (req.url === FALLBACK_USAGE_URL) { - return { - status: 200, - headers: {}, - bodyText: JSON.stringify(successPayload()), - } - } - return { status: 404, headers: {}, bodyText: "{}" } + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + plan_name: "MiniMax Coding Plan", + model_remains: [generalBucket({ current_interval_remaining_percent: 80 })], + }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - - expect(result.lines[0].used).toBe(120) - expect(ctx.host.http.request.mock.calls.length).toBe(2) + expect(result.plan).toBe("Token Plan (GLOBAL)") }) - it("uses CN fallback endpoint when CN primary fails", async () => { + it("derives reset from remains_time when end_time is absent", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) - ctx.host.http.request.mockImplementation((req) => { - if (req.url === CN_PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" } - if (req.url === CN_FALLBACK_USAGE_URL) { - return { - status: 200, - headers: {}, - bodyText: JSON.stringify(successPayload({ - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 1500, // CN Plus: 100 prompts × 15 - current_interval_usage_count: 1200, // Remaining - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - })), - } - } - return { status: 404, headers: {}, bodyText: "{}" } + 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: "general", + current_interval_remaining_percent: 40, + remains_time: 7200, + }, + ], + }), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) + const line = result.lines[0] - expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20 - 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) + expect(line.used).toBe(60) + expect(line.resetsAt).toBe(new Date(1700000000000 + 7200 * 1000).toISOString()) + expect(line.periodDurationMs).toBeUndefined() }) - it("infers CN Starter plan from 600 model-call limit", async () => { + it("supports camelCase modelRemains and epoch-seconds timestamps", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, - bodyText: JSON.stringify( - successPayload({ - plan_name: undefined, // Force inference - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 600, // 40 prompts × 15 - current_interval_usage_count: 500, // Remaining (not used!) - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - }) - ), + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + modelRemains: [ + null, + { + modelName: "general", + current_interval_remaining_percent: 30, + start_time: 1700000000, + end_time: 1700018000, + }, + ], + }), }) const plugin = await loadPlugin() 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 + const line = result.lines[0] + expect(line.label).toBe("Session") + expect(line.used).toBe(70) + expect(line.periodDurationMs).toBe(18000000) + expect(line.resetsAt).toBe(new Date(1700018000 * 1000).toISOString()) }) - it("infers CN Plus plan from 1500 model-call limit", async () => { + it("clamps remaining percent outside 0-100 into used bounds", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, - bodyText: JSON.stringify( - successPayload({ - plan_name: undefined, // Force inference - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 1500, // 100 prompts × 15 - current_interval_usage_count: 1200, // Remaining - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - }) - ), + bodyText: JSON.stringify({ + base_resp: { status_code: 0 }, + model_remains: [ + generalBucket({ + current_interval_remaining_percent: 120, + current_weekly_remaining_percent: -5, + }), + ], + }), }) 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.lines[0].used).toBe(0) + expect(result.lines[1].used).toBe(100) }) - it("infers CN Max plan from 4500 model-call limit", async () => { + it("falls back to secondary endpoint when primary fails", async () => { const ctx = makeCtx() - setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify( - successPayload({ - plan_name: undefined, // Force inference - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 4500, // 300 prompts × 15 - current_interval_usage_count: 2700, // Remaining - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - }) - ), + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockImplementation((req) => { + if (req.url === PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" } + if (req.url === FALLBACK_USAGE_URL) { + return { + status: 200, + headers: {}, + bodyText: JSON.stringify(successPayload()), + } + } + return { status: 404, headers: {}, bodyText: "{}" } }) 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.lines[0].label).toBe("Session") + expect(ctx.host.http.request.mock.calls.length).toBe(2) }) - it("does not infer CN plan for unknown CN model-call limits", async () => { + it("uses CN fallback endpoint when CN primary fails", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify( - successPayload({ - plan_name: undefined, // Force inference - model_remains: [ - { - model_name: "MiniMax-M2", - current_interval_total_count: 9000, // Unknown CN tier - current_interval_usage_count: 6000, // Remaining - start_time: 1700000000000, - end_time: 1700018000000, - }, - ], - }) - ), + ctx.host.http.request.mockImplementation((req) => { + if (req.url === CN_PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" } + if (req.url === CN_FALLBACK_USAGE_URL) { + return { + status: 200, + headers: {}, + bodyText: JSON.stringify(successPayload()), + } + } + return { status: 404, headers: {}, bodyText: "{}" } }) const plugin = await loadPlugin() 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].label).toBe("Session") + 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) }) it("falls back when primary returns auth-like status", async () => { @@ -639,11 +603,26 @@ describe("minimax plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(120) + expect(result.lines[0].label).toBe("Session") expect(ctx.host.http.request.mock.calls.length).toBe(2) }) - it("throws when API returns non-zero base_resp status", async () => { + it("throws on HTTP auth status after exhausting all endpoints", async () => { + const ctx = makeCtx() + setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) + ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }) + const plugin = await loadPlugin() + let message = "" + try { + plugin.probe(ctx) + } catch (e) { + message = String(e) + } + expect(message).toContain("Session expired") + expect(ctx.host.http.request.mock.calls.length).toBe(6) + }) + + it("throws when API returns non-zero base_resp status (cookie missing)", async () => { const ctx = makeCtx() setEnv(ctx, { MINIMAX_API_KEY: "mini-key" }) ctx.host.http.request.mockReturnValue({ @@ -666,78 +645,46 @@ describe("minimax plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired. Check your MiniMax API key.") }) - it("throws when payload has no usable usage data", async () => { + it("throws generic MiniMax API error when status message is absent", 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: [] }), - }) - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") - }) - - it("continues when env getter throws and still uses fallback env var", async () => { - const ctx = makeCtx() - ctx.host.env.get.mockImplementation((name) => { - if (name === "MINIMAX_API_KEY") throw new Error("env unavailable") - if (name === "MINIMAX_API_TOKEN") return "fallback-token" - return null - }) - ctx.host.http.request.mockReturnValue({ - status: 200, - headers: {}, - bodyText: JSON.stringify(successPayload()), + bodyText: JSON.stringify({ + base_resp: { status_code: 429 }, + model_remains: [], + }), }) - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(120) + expect(() => plugin.probe(ctx)).toThrow("MiniMax API error (status 429)") }) - it("supports camelCase modelRemains and explicit used count fields", async () => { + it("throws when payload has no usable usage data", 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 }, - modelRemains: [ - null, - { - currentIntervalTotalCount: "500", - currentIntervalUsedCount: "123", - remainsTime: 7200000, - }, - ], - }), + bodyText: JSON.stringify({ base_resp: { status_code: 0 }, model_remains: [] }), }) - 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() + expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") }) - it("throws generic MiniMax API error when status message is absent", async () => { + it("throws when buckets carry neither percent nor counts", 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: 429 }, - model_remains: [], + base_resp: { status_code: 0 }, + model_remains: [null, { model_name: "general", current_interval_total_count: 0 }], }), }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("MiniMax API error (status 429)") + expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") }) it("throws HTTP error when all endpoints return non-2xx", async () => { @@ -766,178 +713,41 @@ describe("minimax plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data.") }) - it("normalizes bare 'MiniMax Coding Plan' to 'Coding Plan'", 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 }, - plan_name: "MiniMax Coding Plan", - model_remains: [ - { - current_interval_total_count: 100, - current_interval_usage_count: 20, - }, - ], - }), - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - expect(result.plan).toBe("Coding Plan (GLOBAL)") - }) - - it("supports payload.modelRemains and remains-count aliases", 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 }, - plan: "MiniMax Coding Plan: Team", - modelRemains: [ - { - currentIntervalTotalCount: "300", - remainsCount: "120", - endTime: 1700018000000, - }, - ], - }), - }) - - 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) - }) - - it("clamps negative used counts to zero", async () => { + it("continues when env getter throws and still uses fallback env var", 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: 100, - current_interval_used_count: -5, - }, - ], - }), + ctx.host.env.get.mockImplementation((name) => { + if (name === "MINIMAX_API_KEY") throw new Error("env unavailable") + if (name === "MINIMAX_API_TOKEN") return "fallback-token" + return null }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(0) - }) - - it("clamps used counts above total", 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: 100, - current_interval_used_count: 500, - }, - ], - }), + bodyText: JSON.stringify(successPayload()), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].used).toBe(100) + expect(result.lines[0].label).toBe("Session") }) - it("supports epoch seconds for start/end timestamps", async () => { + it("falls back to GLOBAL when MINIMAX_CN_API_KEY lookup throws in AUTO mode", 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: 100, - current_interval_usage_count: 25, - start_time: 1700000000, - end_time: 1700018000, - }, - ], - }), + 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 }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - const line = result.lines[0] - expect(line.periodDurationMs).toBe(18000000) - expect(line.resetsAt).toBe(new Date(1700018000 * 1000).toISOString()) - }) - - it("infers remains_time as milliseconds when value is plausible", 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, - }, - ], - }), + bodyText: JSON.stringify(successPayload()), }) const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.lines[0].resetsAt).toBe(new Date(1700000000000 + 300000).toISOString()) - }) - - it("throws parse error when model_remains entries are unusable", 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: [null, { current_interval_total_count: 0, current_interval_usage_count: 1 }], - }), - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") - }) - it("throws parse error when both used and remaining counts are missing", 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: 100 }], - }), - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") + expect(ctx.host.http.request.mock.calls[0][0].url).toBe(PRIMARY_USAGE_URL) + expect(result.plan).toBe("Plus (GLOBAL)") }) }) From f86a547cdab5331ab83d13d05f894d94e478db01 Mon Sep 17 00:00:00 2001 From: FrankieeW Date: Mon, 1 Jun 2026 23:09:34 +0100 Subject: [PATCH 3/3] docs(minimax): document credit-based dual-window Token Plan Describe the token_plan/remains endpoint (with legacy fallback), the 5h interval + weekly windows, the *_remaining_percent signals, general/video bucket-to-line mapping, the Session+Weekly overview layout, the plan-name priority chain with MINIMAX_PLAN override, and a Tier detection section explaining why the tier cannot be derived from the API. --- docs/providers/minimax.md | 118 +++++++++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 9b78f338..d2543558 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -1,13 +1,16 @@ # MiniMax -> Uses MiniMax Coding Plan remains API with a user-provided API key. +> Uses MiniMax Token Plan remains API with a user-provided API key. ## Overview - **Protocol:** HTTPS (JSON) -- **Endpoint:** `GET https://api.minimax.io/v1/api/openplatform/coding_plan/remains` +- **Endpoint:** `GET https://api.minimax.io/v1/token_plan/remains` — the officially documented Token Plan usage endpoint ([FAQ](https://platform.minimax.io/docs/token-plan/faq)). The older `coding_plan/remains` path returns an identical payload and is kept only as a legacy fallback. - **Auth:** `Authorization: Bearer ` -- **Window model:** dynamic rolling 5-hour limit (per MiniMax Coding Plan docs) +- **Window model:** the Token Plan now enforces **two** windows simultaneously — a rolling 5-hour `interval` window and a `weekly` window. Each `model_remains[]` bucket reports both. +- **Quota model:** Token Plan tiers (`Plus` / `Max` / `Ultra`) are **credit/token based** — model usage draws from a single shared credit pool (`1000 credits = $1`). The remains API deliberately exposes only a usage-bar percentage: `current_interval_total_count` is `0` and there is **no plan/tier field**; it returns `current_interval_remaining_percent` / `current_weekly_remaining_percent` directly. See [Tier detection](#tier-detection). +- **Display note:** OpenUsage renders every line as a percentage (`0`-`100`), computed as `used = 100 − remaining_percent`, so it visually aligns with other providers (claude/codex). +- **CN note:** current CN endpoint is `https://api.minimaxi.com/v1/token_plan/remains`. ## Authentication @@ -21,6 +24,8 @@ The plugin supports automatic region detection and reads API keys based on the s - **CN region**: `MINIMAX_CN_API_KEY` → `MINIMAX_API_KEY` → `MINIMAX_API_TOKEN` - **GLOBAL region**: `MINIMAX_API_KEY` → `MINIMAX_API_TOKEN` +**Optional tier pin:** set `MINIMAX_PLAN` (or `MINIMAX_CODING_PLAN`) to `Plus` / `Max` / `Ultra` to display your tier — the credit-based remains API does not report it. + If no key is found after attempting both regions, it throws: - `MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY.` @@ -30,56 +35,101 @@ If no key is found after attempting both regions, it throws: Request: ```http -GET /v1/api/openplatform/coding_plan/remains HTTP/1.1 +GET /v1/token_plan/remains HTTP/1.1 Host: api.minimax.io Authorization: Bearer Content-Type: application/json Accept: application/json ``` -Fallbacks: +`GLOBAL` endpoints, tried in order: -- `https://api.minimax.io/v1/coding_plan/remains` -- `https://www.minimax.io/v1/api/openplatform/coding_plan/remains` (legacy fallback; can return Cloudflare HTML) +- `https://api.minimax.io/v1/token_plan/remains` (primary, documented) +- `https://www.minimax.io/v1/token_plan/remains` +- `https://api.minimax.io/v1/api/openplatform/coding_plan/remains` (legacy) -When the selected region is `CN`, requests use: +`CN` endpoints, tried in order: -- `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` -- `https://api.minimaxi.com/v1/coding_plan/remains` +- `https://api.minimaxi.com/v1/token_plan/remains` (primary, documented) +- `https://www.minimaxi.com/v1/token_plan/remains` +- `https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains` (legacy) -Expected payload fields: +Expected payload fields (per `model_remains[]` bucket, keyed by `model_name`, e.g. `general`, `video`): - `base_resp.status_code` / `base_resp.status_msg` -- `model_remains[]` -- `model_remains[].current_interval_total_count` -- `model_remains[].current_interval_usage_count` -- optional remaining aliases (`current_interval_remaining_count`, `current_interval_remains_count`) -- `model_remains[].start_time` -- `model_remains[].end_time` -- `model_remains[].remains_time` +- `model_remains[].model_name` +- **5-hour interval window:** + - `current_interval_remaining_percent` (preferred signal) + - `current_interval_total_count` / `current_interval_usage_count` (now `0` for credit-based plans) + - `start_time` / `end_time` / `remains_time` +- **weekly window:** + - `current_weekly_remaining_percent` (preferred signal) + - `current_weekly_total_count` / `current_weekly_usage_count` + - `weekly_start_time` / `weekly_end_time` / `weekly_remains_time` - optional plan fields (`current_subscribe_title`, `plan_name`, `plan`) +Example (CN, abridged): + +```jsonc +{ + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 100, + "start_time": 1780347600000, "end_time": 1780365600000, + "current_weekly_remaining_percent": 100, + "weekly_start_time": 1780243200000, "weekly_end_time": 1780848000000 + }, + { "model_name": "video", "current_interval_remaining_percent": 100, "current_weekly_remaining_percent": 100 } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } +} +``` + ## Usage Mapping -- Treat `current_interval_usage_count` as remaining prompts (MiniMax remains API behavior). -- 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). -- 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. +- Each `model_remains[]` bucket produces up to two percentage lines: one for the 5-hour `interval` window and one for the `weekly` window. +- For each window, OpenUsage prefers the API-provided remaining percent and emits `used = 100 − remaining_percent` (`limit` is always `100`). +- If a window has no `*_remaining_percent` but reports a positive `*_total_count`, it falls back to count math, treating `*_usage_count` as the remaining count (`used = round((total − remaining) / total × 100)`). +- A window is skipped when it carries neither a remaining percent nor a positive total. +- `model_name` maps to line labels: + - `general` → `Session` (interval) and `Weekly` (weekly window) — both shown on the overview, matching claude/codex + - `video` → `Video` (interval) and `Video (Weekly)` + - any other bucket → title-cased `model_name` (interval) and ` (Weekly)` +- Reset timestamp uses `end_time` / `weekly_end_time`; falls back to `remains_time` / `weekly_remains_time` when the end timestamp is absent. +- `periodDurationMs` is `end_time − start_time` for the interval window and `weekly_end_time − weekly_start_time` for the weekly window, when both bounds are valid. +- **Plan name** is resolved by priority, then suffixed with ` (CN)` / ` (GLOBAL)`: + 1. Explicit API plan/title field (`current_subscribe_title`, `plan_name`, `plan`), normalized to a concise label (`Starter` / `Plus` / `Max` / `Ultra`, plus legacy `*-High-Speed` variants). + 2. Per-region count→tier table applied to the `general` bucket's `current_interval_total_count` (legacy/count-based responses only). + 3. The `MINIMAX_PLAN` (or `MINIMAX_CODING_PLAN`) environment override, normalized like an explicit field. + 4. Generic `Token Plan` baseline, so the line is never blank. +- **Why the override exists:** credit/token-based Token Plans expose **no** plan field and report `current_interval_total_count` as `0`, so steps 1–2 cannot resolve a tier. Set `MINIMAX_PLAN=Plus` (or `Max` / `Ultra`) to surface your actual tier. +- Tier-inference tables retained for legacy/count-based responses (per region): + - `GLOBAL`: `1500 => Starter`, `4500 => Plus`, `15000 => Max`, `30000 => Ultra` + - `CN`: `600 => Starter`, `1500 => Plus`, `4500 => Max`, `30000 => Ultra` +- Official tier reference (token/credit based, `Plus` / `Max` / `Ultra`), checked on 2026-06-01: + - 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 - - `resetsAt`: derived from `end_time` or `remains_time` +- **Plan**: resolved by the priority chain above (API field → count tier → `MINIMAX_PLAN` override → generic `Token Plan`), with a ` (CN)` / ` (GLOBAL)` suffix; always present. +- **Overview progress lines** — `general` bucket: + - `Session`: 5-hour interval window (percent, `used` `0`-`100`, `limit` `100`); `resetsAt` from `end_time` or `remains_time` + - `Weekly`: weekly window (percent); `resetsAt` from `weekly_end_time` or `weekly_remains_time` +- **Detail progress lines** (when present): + - `Video` / `Video (Weekly)`: `video` interval and weekly windows (percent) + +## Tier detection + +The subscription tier (`Plus` / `Max` / `Ultra`) **cannot be derived from the API**, by design: + +- The only usage endpoint is `token_plan/remains` (every subscription/plan/quota endpoint variant returns `404`). Its `coding_plan/remains` alias is byte-for-byte identical. +- Per the [Token Plan FAQ](https://platform.minimax.io/docs/token-plan/faq), the upgraded plan is **credit-based** with a single shared credit pool; "the console usage bar is the source of truth for your current available usage." The API surfaces only that bar — `current_interval_remaining_percent` — and reports `current_interval_total_count` as `0`. +- Tiers differ by credit allowance and windows (see the [migration guide](https://platform.minimax.io/docs/token-plan/migration), e.g. Ultra ≈ 10.3B tokens/month), but the remains response returns **no absolute credit/quota value and no tier name**. A percentage alone (0–100) is identical in shape across all tiers, so it cannot distinguish them. +- The only tier-adjacent field, `interval_boost_permille` (observed `2000`), is unconfirmed across tiers and likely a temporary boost multiplier, not a tier identifier. + +Therefore the tier must be supplied manually via `MINIMAX_PLAN` (see [Authentication](#authentication)). If MiniMax later adds a plan field to the response, the priority chain in [Usage Mapping](#usage-mapping) consumes it automatically. ## Errors