From eab8862268d775eac46c1c980103ea1be051ae87 Mon Sep 17 00:00:00 2001 From: Baruch Vitorino <9778282+baruchvitorino@users.noreply.github.com> Date: Tue, 23 Jun 2026 05:31:17 -0300 Subject: [PATCH] fix(quota): handle MiniMax M3/Token Plan API changes (#1589) Extract shared MiniMax provider logic into minimax-shared.js factory module used by both minimax-coding-plan and minimax-cn-coding-plan as thin wrappers. Endpoint fallback: - Try /v1/token_plan/remains (M3/Token Plan) first - Fall back to legacy /v1/api/openplatform/coding_plan/remains - fetchEndpoint wrapped in try/catch so network/parse errors return null instead of throwing, ensuring fallback always runs Model selection (pickChatModel): - Prefer MiniMax-M* entries with non-zero total_count (Token Plan M3) - Fall back to general/chat/text model names (legacy Coding Plan) - Fall back to any entry with current_interval_remaining_percent - Ultimate fallback to model_remains[0] Usage calculation: - token_plan endpoint: usage_count = remaining, so used = total - remaining - coding_plan endpoint: usage_count = consumed (legacy behavior) - Prefer current_interval_remaining_percent when count fields are zero (legacy Coding Plan accounts with percentage-based quotas) - remains_time used as fallback for window duration (in milliseconds, confirmed via live API: 9664502ms = 2.68h in 5h window) Window status: - Respect current_weekly_status field: status 3 means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). These windows are omitted from the result. - Default to active when status field is absent (backward compatible). Fixes #759 (percentage showing empty/null for legacy Coding Plan accounts and incorrect percentages for M3/Token Plan accounts). --- .../web/server/lib/quota/DOCUMENTATION.md | 14 +- .../quota/providers/minimax-cn-coding-plan.js | 151 +---------- .../quota/providers/minimax-coding-plan.js | 150 +---------- .../lib/quota/providers/minimax-shared.js | 250 ++++++++++++++++++ 4 files changed, 288 insertions(+), 277 deletions(-) create mode 100644 packages/web/server/lib/quota/providers/minimax-shared.js diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 2e98dc74..c28aa8e8 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -28,8 +28,8 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | `openrouter` | OpenRouter | `providers/openrouter.js` | `openrouter` | | `zai-coding-plan` | z.ai | `providers/zai.js` | `zai-coding-plan`, `zai`, `z.ai` | | `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` | -| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` | `minimax-coding-plan` | -| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` | `minimax-cn-coding-plan` | +| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` | +| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` | | `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) | | `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` | @@ -53,6 +53,16 @@ All providers should return results via shared helpers to preserve API shape: 6. Update this file with the new provider ID, module path, and alias/auth details. 7. Validate with `bun run type-check`, `bun run lint`, and `bun run build`. +## MiniMax M3 / Token Plan migration + +In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 model release. The API underwent breaking changes: + +- **Endpoint fallback**: The provider tries `/v1/token_plan/remains` (M3) first, falling back to legacy `/v1/api/openplatform/coding_plan/remains`. +- **Field semantics**: On the `token_plan/remains` endpoint, `current_interval_usage_count` returns **remaining** quota (not consumed). The provider computes `used = total - remaining` for this endpoint. The legacy `coding_plan/remains` endpoint retains the old semantics (`usage_count = consumed`). +- **Percentage-based plans**: Legacy Coding Plan accounts return `current_interval_total_count: 0` but include `current_interval_remaining_percent`. The provider prefers this field when count fields are absent. +- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent. +- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows. + ## Notes for contributors - Keep provider IDs stable; clients use them directly. - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. diff --git a/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js b/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js index 5f91f274..98e0e1d5 100644 --- a/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js +++ b/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js @@ -1,140 +1,15 @@ -// MiniMax Coding Plan Provider (minimaxi.com) -import { readAuthFile } from '../../opencode/auth.js'; -import { - getAuthEntry, - normalizeAuthEntry, - buildResult, - toUsageWindow, - toNumber, - toTimestamp, -} from '../utils/index.js'; +import { createMiniMaxCodingPlanProvider } from './minimax-shared.js'; -export const providerId = 'minimax-cn-coding-plan'; -export const providerName = 'MiniMax Coding Plan (minimaxi.com)'; -export const aliases = ['minimax-cn-coding-plan']; +const provider = createMiniMaxCodingPlanProvider({ + providerId: 'minimax-cn-coding-plan', + providerName: 'MiniMax Coding Plan (minimaxi.com)', + aliases: ['minimax-cn-coding-plan'], + tokenPlanUrl: 'https://api.minimaxi.com/v1/token_plan/remains', + codingPlanUrl: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains', +}); -export const isConfigured = () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - return Boolean(entry?.key || entry?.token); -}; - -export const fetchQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: false, - error: 'Not configured', - }); - } - - try { - const response = await fetch( - 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains', - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - } - ); - - if (!response.ok) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: `API error: ${response.status}`, - }); - } - - const payload = await response.json(); - const baseResp = payload?.base_resp; - if (baseResp && baseResp.status_code !== 0) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: baseResp.status_msg || `API error: ${baseResp.status_code}`, - }); - } - - const firstModel = payload?.model_remains?.[0]; - if (!firstModel) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: 'No model quota data available', - }); - } - - const intervalTotal = toNumber(firstModel.current_interval_total_count); - const intervalUsage = toNumber(firstModel.current_interval_usage_count); - const intervalStartAt = toTimestamp(firstModel.start_time); - const intervalResetAt = toTimestamp(firstModel.end_time); - const weeklyTotal = toNumber(firstModel.current_weekly_total_count); - const weeklyUsage = toNumber(firstModel.current_weekly_usage_count); - const weeklyStartAt = toTimestamp(firstModel.weekly_start_time); - const weeklyResetAt = toTimestamp(firstModel.weekly_end_time); - - const intervalUsed = intervalTotal - intervalUsage; - const weeklyUsed = weeklyTotal - weeklyUsage; - - const intervalUsedPercent = - intervalTotal > 0 && intervalUsed != null - ? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100)) - : null; - const intervalWindowSeconds = - intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt - ? Math.floor((intervalResetAt - intervalStartAt) / 1000) - : null; - const weeklyUsedPercent = - weeklyTotal > 0 && weeklyUsed != null - ? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100)) - : null; - const weeklyWindowSeconds = - weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt - ? Math.floor((weeklyResetAt - weeklyStartAt) / 1000) - : null; - - const windows = { - '5h': toUsageWindow({ - usedPercent: intervalUsedPercent, - windowSeconds: intervalWindowSeconds, - resetAt: intervalResetAt, - }), - weekly: toUsageWindow({ - usedPercent: weeklyUsedPercent, - windowSeconds: weeklyWindowSeconds, - resetAt: weeklyResetAt, - }), - }; - - return buildResult({ - providerId, - providerName, - ok: true, - configured: true, - usage: { windows }, - }); - } catch (error) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed', - }); - } -}; +export const providerId = provider.providerId; +export const providerName = provider.providerName; +export const aliases = provider.aliases; +export const isConfigured = provider.isConfigured; +export const fetchQuota = provider.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/minimax-coding-plan.js b/packages/web/server/lib/quota/providers/minimax-coding-plan.js index ae74f75f..d800cb26 100644 --- a/packages/web/server/lib/quota/providers/minimax-coding-plan.js +++ b/packages/web/server/lib/quota/providers/minimax-coding-plan.js @@ -1,139 +1,15 @@ -import { readAuthFile } from '../../opencode/auth.js'; -import { - getAuthEntry, - normalizeAuthEntry, - buildResult, - toUsageWindow, - toNumber, - toTimestamp, -} from '../utils/index.js'; +import { createMiniMaxCodingPlanProvider } from './minimax-shared.js'; -export const providerId = 'minimax-coding-plan'; -export const providerName = 'MiniMax Coding Plan (minimax.io)'; -export const aliases = ['minimax-coding-plan']; +const provider = createMiniMaxCodingPlanProvider({ + providerId: 'minimax-coding-plan', + providerName: 'MiniMax Coding Plan (minimax.io)', + aliases: ['minimax-coding-plan'], + tokenPlanUrl: 'https://api.minimax.io/v1/token_plan/remains', + codingPlanUrl: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains', +}); -export const isConfigured = () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - return Boolean(entry?.key || entry?.token); -}; - -export const fetchQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: false, - error: 'Not configured', - }); - } - - try { - const response = await fetch( - 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains', - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - } - ); - - if (!response.ok) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: `API error: ${response.status}`, - }); - } - - const payload = await response.json(); - const baseResp = payload?.base_resp; - if (baseResp && baseResp.status_code !== 0) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: baseResp.status_msg || `API error: ${baseResp.status_code}`, - }); - } - - const firstModel = payload?.model_remains?.[0]; - if (!firstModel) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: 'No model quota data available', - }); - } - - const intervalTotal = toNumber(firstModel.current_interval_total_count); - const intervalUsage = toNumber(firstModel.current_interval_usage_count); - const intervalStartAt = toTimestamp(firstModel.start_time); - const intervalResetAt = toTimestamp(firstModel.end_time); - const weeklyTotal = toNumber(firstModel.current_weekly_total_count); - const weeklyUsage = toNumber(firstModel.current_weekly_usage_count); - const weeklyStartAt = toTimestamp(firstModel.weekly_start_time); - const weeklyResetAt = toTimestamp(firstModel.weekly_end_time); - - const intervalUsed = intervalUsage; - const weeklyUsed = weeklyUsage; - - const intervalUsedPercent = - intervalTotal > 0 && intervalUsed !== null - ? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100)) - : null; - const intervalWindowSeconds = - intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt - ? Math.floor((intervalResetAt - intervalStartAt) / 1000) - : null; - const weeklyUsedPercent = - weeklyTotal > 0 && weeklyUsed !== null - ? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100)) - : null; - const weeklyWindowSeconds = - weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt - ? Math.floor((weeklyResetAt - weeklyStartAt) / 1000) - : null; - - const windows = { - '5h': toUsageWindow({ - usedPercent: intervalUsedPercent, - windowSeconds: intervalWindowSeconds, - resetAt: intervalResetAt, - }), - weekly: toUsageWindow({ - usedPercent: weeklyUsedPercent, - windowSeconds: weeklyWindowSeconds, - resetAt: weeklyResetAt, - }), - }; - - return buildResult({ - providerId, - providerName, - ok: true, - configured: true, - usage: { windows }, - }); - } catch (error) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed', - }); - } -}; +export const providerId = provider.providerId; +export const providerName = provider.providerName; +export const aliases = provider.aliases; +export const isConfigured = provider.isConfigured; +export const fetchQuota = provider.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/minimax-shared.js b/packages/web/server/lib/quota/providers/minimax-shared.js new file mode 100644 index 00000000..c7555d6e --- /dev/null +++ b/packages/web/server/lib/quota/providers/minimax-shared.js @@ -0,0 +1,250 @@ +import { readAuthFile } from '../../opencode/auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp, +} from '../utils/index.js'; + +// Status 3 indicates the window is not applicable for the current plan tier. +const WINDOW_STATUS_INACTIVE = 3; + +const TEXT_MODELS = ['general', 'chat', 'text']; + +const pickChatModel = (modelRemains) => { + if (!Array.isArray(modelRemains) || modelRemains.length === 0) return null; + + const m3Candidate = modelRemains.find( + (m) => m?.model_name && /^minimax-m/i.test(m.model_name) && toNumber(m.current_interval_total_count) > 0 + ); + if (m3Candidate) return m3Candidate; + + const textCandidate = modelRemains.find( + (m) => m?.model_name && TEXT_MODELS.includes(m.model_name.toLowerCase()) + ); + if (textCandidate) return textCandidate; + + const percentCandidate = modelRemains.find( + (m) => typeof m?.current_interval_remaining_percent === 'number' + ); + if (percentCandidate) return percentCandidate; + + return modelRemains[0]; +}; + +const isUsablePayload = (payload) => { + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) return false; + const rems = payload?.model_remains; + return Array.isArray(rems) && rems.length > 0; +}; + +const fetchEndpoint = async (url, apiKey) => { + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) return null; + const payload = await response.json(); + if (!isUsablePayload(payload)) return null; + return payload; + } catch { + return null; + } +}; + +const coercePercent = (value) => { + const n = toNumber(value); + return n !== null ? Math.max(0, Math.min(100, n)) : null; +}; + +/** + * Check if a window (interval or weekly) is active for the current plan. + * Status 3 means the window is not applicable (e.g. legacy plans without weekly limits). + * When the status field is absent, default to active. + */ +const isWindowActive = (status) => { + const n = toNumber(status); + return n === null || n !== WINDOW_STATUS_INACTIVE; +}; + +/** + * Calculate window duration in seconds from API timestamps or remains_time. + * MiniMax API returns remains_time in milliseconds (confirmed via live API testing: + * 9664502 ms = 2.68h in a 5h window, consistent with remaining_percent). + */ +const calculateWindowSeconds = (startAt, resetAt, remainsTimeMs) => { + if (startAt && resetAt && resetAt > startAt) { + return Math.floor((resetAt - startAt) / 1000); + } + if (remainsTimeMs && remainsTimeMs > 0) { + return Math.floor(remainsTimeMs / 1000); + } + return null; +}; + +const calculateUsage = (model, isTokenPlan) => { + const intervalTotal = toNumber(model.current_interval_total_count); + const intervalUsageRaw = toNumber(model.current_interval_usage_count); + const intervalStartAt = toTimestamp(model.start_time); + const intervalResetAt = toTimestamp(model.end_time); + const intervalRemainsTime = toNumber(model.remains_time); + const intervalRemainingPercent = coercePercent(model.current_interval_remaining_percent); + + const weeklyTotal = toNumber(model.current_weekly_total_count); + const weeklyUsageRaw = toNumber(model.current_weekly_usage_count); + const weeklyStartAt = toTimestamp(model.weekly_start_time); + const weeklyResetAt = toTimestamp(model.weekly_end_time); + const weeklyRemainsTime = toNumber(model.weekly_remains_time); + const weeklyRemainingPercent = coercePercent(model.current_weekly_remaining_percent); + + let intervalUsedPercent = null; + if (intervalRemainingPercent !== null) { + intervalUsedPercent = 100 - intervalRemainingPercent; + } else if (intervalTotal > 0 && intervalUsageRaw !== null) { + const intervalUsed = isTokenPlan + ? Math.max(0, intervalTotal - intervalUsageRaw) + : intervalUsageRaw; + intervalUsedPercent = Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100)); + } + + let weeklyUsedPercent = null; + if (weeklyRemainingPercent !== null) { + weeklyUsedPercent = 100 - weeklyRemainingPercent; + } else if (weeklyTotal > 0 && weeklyUsageRaw !== null) { + const weeklyUsed = isTokenPlan + ? Math.max(0, weeklyTotal - weeklyUsageRaw) + : weeklyUsageRaw; + weeklyUsedPercent = Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100)); + } + + const intervalWindowSeconds = calculateWindowSeconds(intervalStartAt, intervalResetAt, intervalRemainsTime); + const weeklyWindowSeconds = calculateWindowSeconds(weeklyStartAt, weeklyResetAt, weeklyRemainsTime); + + return { + intervalUsedPercent, + intervalWindowSeconds, + intervalResetAt, + weeklyUsedPercent, + weeklyWindowSeconds, + weeklyResetAt, + }; +}; + +export const createMiniMaxCodingPlanProvider = ({ providerId, providerName, aliases, tokenPlanUrl, codingPlanUrl }) => { + const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); + }; + + const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + let payload = await fetchEndpoint(tokenPlanUrl, apiKey); + let isTokenPlan = true; + + if (!payload) { + payload = await fetchEndpoint(codingPlanUrl, apiKey); + isTokenPlan = false; + } + + if (!payload) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'API returned no usable quota data', + }); + } + + const model = pickChatModel(payload.model_remains); + if (!model) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No model quota data available', + }); + } + + const { + intervalUsedPercent, + intervalWindowSeconds, + intervalResetAt, + weeklyUsedPercent, + weeklyWindowSeconds, + weeklyResetAt, + } = calculateUsage(model, isTokenPlan); + + const windows = { + '5h': toUsageWindow({ + usedPercent: intervalUsedPercent, + windowSeconds: intervalWindowSeconds, + resetAt: intervalResetAt, + }), + }; + + // Only include the weekly window when the plan tier supports it. + // Status 3 = not applicable (e.g. legacy Coding Plan without weekly limits). + const weeklyActive = isWindowActive(model.current_weekly_status); + const hasWeeklyData = + weeklyActive && + (coercePercent(model.current_weekly_remaining_percent) !== null || + toNumber(model.current_weekly_total_count) > 0); + + if (hasWeeklyData) { + windows.weekly = toUsageWindow({ + usedPercent: weeklyUsedPercent, + windowSeconds: weeklyWindowSeconds, + resetAt: weeklyResetAt, + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } + }; + + return { + providerId, + providerName, + aliases, + isConfigured, + fetchQuota, + }; +};