Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions docs/providers/minimax.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@

## Authentication

The plugin reads API key from environment in this order:
The plugin supports automatic region detection and reads API keys based on the selected region:

1. `MINIMAX_API_KEY`
2. `MINIMAX_API_TOKEN`
**Region auto-selection:**
- If `MINIMAX_CN_API_KEY` is set: tries `CN` first, then `GLOBAL`
- If `MINIMAX_CN_API_KEY` is not set: tries `GLOBAL` first, then `CN`

If no key is found, it throws:
**Key lookup by region:**
- **CN region**: `MINIMAX_CN_API_KEY` → `MINIMAX_API_KEY` → `MINIMAX_API_TOKEN`
- **GLOBAL region**: `MINIMAX_API_KEY` → `MINIMAX_API_TOKEN`

- `MiniMax API key missing. Set MINIMAX_API_KEY.`
If no key is found after attempting both regions, it throws:

- `MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY.`

## Data Source

Expand All @@ -37,6 +42,11 @@ Fallbacks:
- `https://api.minimax.io/v1/coding_plan/remains`
- `https://www.minimax.io/v1/api/openplatform/coding_plan/remains` (legacy fallback; can return Cloudflare HTML)

When the selected region is `CN`, requests use:

- `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains`
- `https://api.minimaxi.com/v1/coding_plan/remains`

Expected payload fields:

- `base_resp.status_code` / `base_resp.status_msg`
Expand All @@ -55,14 +65,15 @@ Expected payload fields:
- 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, 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 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.

## Output

- **Plan**: best-effort from API payload (normalized to concise label)
- **Plan**: best-effort from API payload (normalized to concise label, with ` (CN)` or ` (GLOBAL)` suffix)
- **Session** (overview progress line):
- `label`: `Session`
- `format`: count (`prompts`)
Expand All @@ -74,7 +85,7 @@ Expected payload fields:

| Condition | Message |
|---|---|
| Missing API key | `MiniMax API key missing. Set MINIMAX_API_KEY.` |
| Missing API key | `MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY.` |
| HTTP 401/403 | `Session expired. Check your MiniMax API key.` |
| API status `base_resp.status_code != 0` | `MiniMax API error: ...` (or session-expired for auth-like errors) |
| Non-2xx | `Request failed (HTTP {status}). Try again later.` |
Expand Down
216 changes: 146 additions & 70 deletions plugins/minimax/plugin.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
(function () {
const PRIMARY_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains"
const FALLBACK_USAGE_URLS = [
const GLOBAL_PRIMARY_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_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",
]
const API_KEY_ENV_VARS = ["MINIMAX_API_KEY", "MINIMAX_API_TOKEN"]
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
const PROMPT_LIMIT_TO_PLAN = {
// GLOBAL plan tiers (based on prompt limits)
const GLOBAL_PROMPT_LIMIT_TO_PLAN = {
100: "Starter",
300: "Plus",
1000: "Max",
2000: "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 = {
600: "Starter",
1500: "Plus",
4500: "Max",
}
const MODEL_CALLS_PER_PROMPT = 15

function readString(value) {
Expand Down Expand Up @@ -48,16 +59,21 @@
return compact
}

function inferPlanNameFromLimit(totalCount) {
function inferPlanNameFromLimit(totalCount, endpointSelection) {
const n = readNumber(totalCount)
if (n === null || n <= 0) return null

const normalized = Math.round(n)
if (PROMPT_LIMIT_TO_PLAN[normalized]) return PROMPT_LIMIT_TO_PLAN[normalized]
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 (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 PROMPT_LIMIT_TO_PLAN[inferredPromptLimit] || null
return GLOBAL_PROMPT_LIMIT_TO_PLAN[inferredPromptLimit] || null
}

function epochToMs(epoch) {
Expand Down Expand Up @@ -96,9 +112,10 @@
return secOverflow <= msOverflow ? asSecondsMs : asMillisecondsMs
}

function loadApiKey(ctx) {
for (let i = 0; i < API_KEY_ENV_VARS.length; i += 1) {
const name = API_KEY_ENV_VARS[i]
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) {
const name = envVars[i]
let value = null
try {
value = ctx.host.env.get(name)
Expand All @@ -108,13 +125,94 @@
const key = readString(value)
if (key) {
ctx.host.log.info("api key loaded from " + name)
return key
return { value: key, source: name }
}
}
return null
}

function parsePayloadShape(ctx, payload) {
function getUsageUrls(endpointSelection) {
if (endpointSelection === "CN") {
return [CN_PRIMARY_USAGE_URL].concat(CN_FALLBACK_USAGE_URLS)
}
return [GLOBAL_PRIMARY_USAGE_URL].concat(GLOBAL_FALLBACK_USAGE_URLS)
}

function endpointAttempts(ctx) {
// AUTO: if CN key exists, try CN first; otherwise try GLOBAL first.
let cnApiKeyValue = null
try {
cnApiKeyValue = ctx.host.env.get("MINIMAX_CN_API_KEY")
} catch (e) {
ctx.host.log.warn("env read failed for MINIMAX_CN_API_KEY: " + String(e))
}
if (readString(cnApiKeyValue)) return ["CN", "GLOBAL"]
return ["GLOBAL", "CN"]
}

function formatAuthError() {
return "Session expired. Check your MiniMax API key."
}

/**
* Tries multiple URL candidates and returns the first successful response.
* @returns {object} parsed JSON response
* @throws {string} error message
*/
function tryUrls(ctx, urls, apiKey) {
let lastStatus = null
let hadNetworkError = false
let authStatusCount = 0

for (let i = 0; i < urls.length; i += 1) {
const url = urls[i]
let resp
try {
resp = ctx.util.request({
method: "GET",
url: url,
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
timeoutMs: 15000,
})
} catch (e) {
hadNetworkError = true
ctx.host.log.warn("request failed (" + url + "): " + String(e))
continue
}

if (ctx.util.isAuthStatus(resp.status)) {
authStatusCount += 1
ctx.host.log.warn("request returned auth status " + resp.status + " (" + url + ")")
continue
}
if (resp.status < 200 || resp.status >= 300) {
lastStatus = resp.status
ctx.host.log.warn("request returned status " + resp.status + " (" + url + ")")
continue
}

const parsed = ctx.util.tryParseJson(resp.bodyText)
if (!parsed || typeof parsed !== "object") {
ctx.host.log.warn("request returned invalid JSON (" + url + ")")
continue
}

return parsed
}

if (authStatusCount > 0 && lastStatus === null && !hadNetworkError) {
throw formatAuthError()
}
if (lastStatus !== null) throw "Request failed (HTTP " + lastStatus + "). Try again later."
if (hadNetworkError) throw "Request failed. Check your connection."
throw "Could not parse usage data."
}

function parsePayloadShape(ctx, payload, endpointSelection) {
if (!payload || typeof payload !== "object") return null

const data = payload.data && typeof payload.data === "object" ? payload.data : payload
Expand All @@ -130,7 +228,7 @@
normalized.includes("log in") ||
normalized.includes("login")
) {
throw "Session expired. Check your MiniMax API key."
throw formatAuthError()
}
throw statusMessage
? "MiniMax API error: " + statusMessage
Expand Down Expand Up @@ -220,7 +318,7 @@
payload.plan_name,
payload.plan,
]))
const inferredPlanName = inferPlanNameFromLimit(total)
const inferredPlanName = inferPlanNameFromLimit(total, endpointSelection)
const planName = explicitPlanName || inferredPlanName

return {
Expand All @@ -232,79 +330,57 @@
}
}

function fetchUsagePayload(ctx, apiKey) {
const urls = [PRIMARY_USAGE_URL].concat(FALLBACK_USAGE_URLS)
let lastStatus = null
let hadNetworkError = false
let authStatusCount = 0
function fetchUsagePayload(ctx, apiKey, endpointSelection) {
return tryUrls(ctx, getUsageUrls(endpointSelection), apiKey)
}

for (let i = 0; i < urls.length; i += 1) {
const url = urls[i]
let resp
function probe(ctx) {
const attempts = endpointAttempts(ctx)
let lastError = null
let parsed = null
let successfulEndpoint = null

for (let i = 0; i < attempts.length; i += 1) {
const endpoint = attempts[i]
const apiKeyInfo = loadApiKey(ctx, endpoint)
if (!apiKeyInfo) continue
try {
resp = ctx.util.request({
method: "GET",
url: url,
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
timeoutMs: 15000,
})
const payload = fetchUsagePayload(ctx, apiKeyInfo.value, endpoint)
parsed = parsePayloadShape(ctx, payload, endpoint)
if (parsed) {
successfulEndpoint = endpoint
break
}
if (!lastError) lastError = "Could not parse usage data."
} catch (e) {
hadNetworkError = true
ctx.host.log.warn("request failed (" + url + "): " + String(e))
continue
if (!lastError) lastError = String(e)
}

if (ctx.util.isAuthStatus(resp.status)) {
authStatusCount += 1
ctx.host.log.warn("request returned auth status " + resp.status + " (" + url + ")")
continue
}
if (resp.status < 200 || resp.status >= 300) {
lastStatus = resp.status
ctx.host.log.warn("request returned status " + resp.status + " (" + url + ")")
continue
}

const parsed = ctx.util.tryParseJson(resp.bodyText)
if (!parsed || typeof parsed !== "object") {
ctx.host.log.warn("request returned invalid JSON (" + url + ")")
continue
}

return parsed
}

if (authStatusCount > 0 && lastStatus === null && !hadNetworkError) {
throw "Session expired. Check your MiniMax API key."
if (!parsed) {
if (lastError) throw lastError
throw "MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY."
}
if (lastStatus !== null) throw "Request failed (HTTP " + lastStatus + "). Try again later."
if (hadNetworkError) throw "Request failed. Check your connection."
throw "Could not parse usage data."
}

function probe(ctx) {
const apiKey = loadApiKey(ctx)
if (!apiKey) throw "MiniMax API key missing. Set MINIMAX_API_KEY."

const payload = fetchUsagePayload(ctx, apiKey)
const parsed = parsePayloadShape(ctx, payload)
if (!parsed) throw "Could not parse usage data."
// 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: parsed.used,
limit: parsed.total,
used: Math.round(parsed.used * displayMultiplier),
limit: Math.round(parsed.total * displayMultiplier),
format: { kind: "count", suffix: "prompts" },
Comment on lines 370 to 374
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions a visible session label like Session (CN) / Session (GLOBAL), but this code keeps the progress line label as Session and instead adds the region suffix to result.plan. Either update the session line label to include the region, or adjust the PR description to match the implemented behavior.

Copilot uses AI. Check for mistakes.
}
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) result.plan = parsed.planName
if (parsed.planName) {
const regionLabel = successfulEndpoint === "CN" ? " (CN)" : " (GLOBAL)"
result.plan = parsed.planName + regionLabel
}
return result
}

Expand Down
Loading