Skip to content
Closed
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
17 changes: 16 additions & 1 deletion docs/providers/minimax.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ If no key is found, it throws:

- `MiniMax API key missing. Set MINIMAX_API_KEY.`

Endpoint selection:

- Fully automatic (no endpoint env var needed).
- Region attempt order:
- with `MINIMAX_CN_API_KEY`: `CN -> GLOBAL`
- without `MINIMAX_CN_API_KEY`: `GLOBAL -> CN`
- Key lookup by region:
- in `CN`: `MINIMAX_CN_API_KEY` -> `MINIMAX_API_KEY` -> `MINIMAX_API_TOKEN`
- in `GLOBAL`: `MINIMAX_API_KEY` -> `MINIMAX_API_TOKEN`

## Data Source

Request:
Expand All @@ -37,6 +47,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 Down Expand Up @@ -64,7 +79,7 @@ Expected payload fields:

- **Plan**: best-effort from API payload (normalized to concise label)
- **Session** (overview progress line):
- `label`: `Session`
- `label`: `Session (CN)` or `Session (GLOBAL)` based on selected endpoint
- `format`: count (`prompts`)
- `used`: computed used prompts
- `limit`: total prompt limit for current window
Expand Down
92 changes: 74 additions & 18 deletions plugins/minimax/plugin.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
(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 = {
Expand Down Expand Up @@ -96,9 +99,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 +112,44 @@
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 loadEndpointSelection(ctx) {
// Always auto-detect region from available keys and probe result.
return "AUTO"
}

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, selection) {
if (selection === "CN") return ["CN"]
if (selection === "GLOBAL") return ["GLOBAL"]

// 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."
}

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

const data = payload.data && typeof payload.data === "object" ? payload.data : payload
Expand All @@ -130,7 +165,7 @@
normalized.includes("log in") ||
normalized.includes("login")
) {
throw "Session expired. Check your MiniMax API key."
throw formatAuthError(endpointSelection, keySource)
}
throw statusMessage
? "MiniMax API error: " + statusMessage
Expand Down Expand Up @@ -232,8 +267,8 @@
}
}

function fetchUsagePayload(ctx, apiKey) {
const urls = [PRIMARY_USAGE_URL].concat(FALLBACK_USAGE_URLS)
function fetchUsagePayload(ctx, apiKey, endpointSelection, keySource) {
const urls = getUsageUrls(endpointSelection)
let lastStatus = null
let hadNetworkError = false
let authStatusCount = 0
Expand Down Expand Up @@ -279,23 +314,44 @@
}

if (authStatusCount > 0 && lastStatus === null && !hadNetworkError) {
throw "Session expired. Check your MiniMax API key."
throw formatAuthError(endpointSelection, keySource)
}
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 endpointSelection = loadEndpointSelection(ctx)
const attempts = endpointAttempts(ctx, endpointSelection)
let lastError = null
let parsed = null
let resolvedEndpoint = null

for (let i = 0; i < attempts.length; i += 1) {
const endpoint = attempts[i]
const apiKeyInfo = loadApiKey(ctx, endpoint)
if (!apiKeyInfo) continue
try {
const payload = fetchUsagePayload(ctx, apiKeyInfo.value, endpoint, apiKeyInfo.source)
parsed = parsePayloadShape(ctx, payload, endpoint, apiKeyInfo.source)
if (parsed) {
resolvedEndpoint = endpoint
break
}
lastError = "Could not parse usage data."
} catch (e) {
lastError = String(e)

Choose a reason for hiding this comment

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

P2 Badge Preserve non-auth errors across AUTO endpoint retries

This assignment unconditionally replaces prior attempt failures with the last endpoint’s error, so AUTO mode can misreport outages as credential problems. For example, if GLOBAL returns a real server failure (Request failed (HTTP 500) from line 319) and the CN retry returns auth, the final error becomes Session expired, which sends users to rotate keys instead of treating it as service unavailability. Keep the higher-signal non-auth failure when later retries only add auth noise.

Useful? React with 👍 / 👎.

}
}

const payload = fetchUsagePayload(ctx, apiKey)
const parsed = parsePayloadShape(ctx, payload)
if (!parsed) throw "Could not parse usage data."
if (!parsed) {
if (lastError) throw lastError
throw "MiniMax API key missing. Set MINIMAX_API_KEY."
}

const line = {
label: "Session",
label: resolvedEndpoint ? "Session (" + resolvedEndpoint + ")" : "Session",

Choose a reason for hiding this comment

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

P1 Badge Keep MiniMax progress label stable across regions

Changing the emitted label to Session (CN)/Session (GLOBAL) breaks downstream matching because the manifest still declares Session as the primary overview line (plugins/minimax/plugin.json:10), and both overview filtering and tray primary selection use exact label equality (src/components/provider-card.tsx:174-184, src/lib/tray-primary-progress.ts:65-71). In those contexts, MiniMax usage data is produced but not recognized, so users can see missing overview progress and --% tray output.

Useful? React with 👍 / 👎.

used: parsed.used,
limit: parsed.total,
format: { kind: "count", suffix: "prompts" },
Expand Down
145 changes: 142 additions & 3 deletions plugins/minimax/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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 loadPlugin = async () => {
await import("./plugin.js")
Expand Down Expand Up @@ -89,6 +91,105 @@ describe("minimax plugin", () => {
expect(call.headers.Authorization).toBe("Bearer token-fallback")
})

it("auto-selects CN endpoint when MINIMAX_CN_API_KEY exists", 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()
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")
})

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()
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")
})

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" })
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)
})

it("falls back to CN in AUTO mode when GLOBAL auth fails", async () => {
const ctx = makeCtx()
setEnv(ctx, { MINIMAX_API_KEY: "global-key" })
ctx.host.http.request.mockImplementation((req) => {
if (req.url === PRIMARY_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
if (req.url === FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
if (req.url === LEGACY_WWW_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
if (req.url === CN_PRIMARY_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.lines[0].used).toBe(120)
const first = ctx.host.http.request.mock.calls[0][0].url
const last = ctx.host.http.request.mock.calls[ctx.host.http.request.mock.calls.length - 1][0].url
expect(first).toBe(PRIMARY_USAGE_URL)
expect(last).toBe(CN_PRIMARY_USAGE_URL)
})

it("parses usage, plan, reset timestamp, and period duration", async () => {
const ctx = makeCtx()
setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
Expand All @@ -104,7 +205,7 @@ describe("minimax plugin", () => {
expect(result.plan).toBe("Plus")
expect(result.lines.length).toBe(1)
const line = result.lines[0]
expect(line.label).toBe("Session")
expect(line.label).toBe("Session (GLOBAL)")
expect(line.type).toBe("progress")
expect(line.used).toBe(120) // current_interval_usage_count is remaining
expect(line.limit).toBe(300)
Expand Down Expand Up @@ -286,8 +387,14 @@ describe("minimax plugin", () => {
setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" })
const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired")
expect(ctx.host.http.request.mock.calls.length).toBe(3)
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)
})

it("falls back to secondary endpoint when primary fails", async () => {
Expand All @@ -312,6 +419,30 @@ describe("minimax plugin", () => {
expect(ctx.host.http.request.mock.calls.length).toBe(2)
})

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.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.lines[0].used).toBe(120)
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 () => {
const ctx = makeCtx()
setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
Expand Down Expand Up @@ -350,6 +481,14 @@ describe("minimax plugin", () => {
expect(() => plugin.probe(ctx)).toThrow("Session expired")
})

it("uses same generic auth error text for CN path", async () => {
const ctx = makeCtx()
setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" })
const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired. Check your MiniMax API key.")
})

it("throws when payload has no usable usage data", async () => {
const ctx = makeCtx()
setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};

const WHITELISTED_ENV_VARS: [&str; 5] = [
const WHITELISTED_ENV_VARS: [&str; 6] = [
"CODEX_HOME",
"ZAI_API_KEY",
"GLM_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_API_TOKEN",
"MINIMAX_CN_API_KEY",
];

fn last_non_empty_trimmed_line(text: &str) -> Option<String> {
Expand Down