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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Automatic quota toasts after assistant responses
- Manual `/quota`, `/pricing_refresh`, and `/tokens_*` commands for deeper local reporting with zero context window pollution

**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, Chutes AI, Firmware AI, Google Antigravity, Z.ai coding plan, and NanoGPT.
**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, MiniMax Coding Plan, Chutes AI, Firmware AI, Google Antigravity, Z.ai coding plan, and NanoGPT.

**Token reports**: All models and providers in [models.dev](https://models.dev), plus deterministic local pricing for Cursor Auto/Composer and Cursor model aliases that are not on models.dev.

Expand Down Expand Up @@ -89,6 +89,7 @@ That is enough for most installs. Providers are auto-detected from your existing
| **NanoGPT** | Usually | User/global OpenCode config, env, or auth.json. |
| **Google Antigravity** | Needs [quick setup](#google-antigravity-quick-setup) | Companion auth plugin. |
| **Z.ai** | Yes | OpenCode auth. |
| **MiniMax Coding Plan** | Yes | Existing OpenCode provider config + auth.json `minimax-coding-plan` section. |

<a id="anthropic-quick-setup"></a>
<details>
Expand Down Expand Up @@ -317,6 +318,31 @@ Example fallback tier:

</details>


<a id="minimax-coding-plan-notes"></a>
<details>
<summary><strong>MiniMax Coding Plan</strong></summary>

If OpenCode is already configured with the `minimax-coding-plan` provider and your `auth.json` has a `minimax-coding-plan` entry, quota detection works automatically. No additional plugin is required.

The plugin reads `key` first and falls back to `access` from that auth entry. Quota is fetched from the MiniMax API using those stored credentials.

Example `auth.json` entry:

```json
{
"minimax-coding-plan": {
"type": "api",
"key": "YOUR_MINIMAX_API_KEY"
}
}
```

- `MiniMax-M*` models — rolling 5-hour interval + weekly
- `/quota_status` shows auth detection, API-key diagnostics, live quota state, and endpoint errors

</details>

<a id="firmware-ai-notes"></a>
<details>
<summary><strong>Firmware AI</strong></summary>
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type {
CopilotQuotaResult,
GoogleQuotaResult,
GoogleModelQuota,
MiniMaxResult,
MiniMaxResultEntry,
} from "./lib/types.js";

// NOTE: tool exports are part of the plugin runtime contract and are not
Expand Down
81 changes: 81 additions & 0 deletions src/lib/minimax-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* MiniMax auth resolver
*
* Reads MiniMax credentials from OpenCode auth.json and resolves
* them into a standardized format for the MiniMax Coding Plan provider.
*/

import type { AuthData, MiniMaxAuthData } from "./types.js";
import { sanitizeDisplayText } from "./display-sanitize.js";
import { readAuthFileCached } from "./opencode-auth.js";

export const DEFAULT_MINIMAX_AUTH_CACHE_MAX_AGE_MS = 5_000;

export type ResolvedMiniMaxAuth =
| { state: "none" }
| { state: "configured"; apiKey: string }
| { state: "invalid"; error: string };

function getMiniMaxAuthEntry(auth: AuthData | null | undefined): unknown {
return auth?.["minimax-coding-plan"];
}

function isMiniMaxAuthData(value: unknown): value is MiniMaxAuthData {
return value !== null && typeof value === "object";
}

function getMiniMaxCredential(auth: MiniMaxAuthData): string {
const key = typeof auth.key === "string" ? auth.key.trim() : "";
const access = typeof auth.access === "string" ? auth.access.trim() : "";
return key || access || "";
}

function sanitizeMiniMaxAuthValue(value: string): string {
const sanitized = sanitizeDisplayText(value).replace(/\s+/g, " ").trim();
return (sanitized || "unknown").slice(0, 120);
}

/**
* Resolve MiniMax auth from the full auth data.
*
* Returns `"none"` when no minimax-coding-plan entry exists,
* `"invalid"` when the entry exists but has wrong type or empty credentials,
* and `"configured"` when a usable API key is found.
*/
export function resolveMiniMaxAuth(auth: AuthData | null | undefined): ResolvedMiniMaxAuth {
const minimax = getMiniMaxAuthEntry(auth);
if (minimax === null || minimax === undefined) {
return { state: "none" };
}

if (!isMiniMaxAuthData(minimax)) {
return { state: "invalid", error: "MiniMax auth entry has invalid shape" };
}

if (typeof minimax.type !== "string") {
return { state: "invalid", error: "MiniMax auth entry present but type is missing or invalid" };
}

if (minimax.type !== "api") {
return {
state: "invalid",
error: `Unsupported MiniMax auth type: "${sanitizeMiniMaxAuthValue(minimax.type)}"`,
};
}

const credential = getMiniMaxCredential(minimax);
if (!credential) {
return { state: "invalid", error: "MiniMax auth entry present but credentials are empty" };
}

return { state: "configured", apiKey: credential };
}

export async function resolveMiniMaxAuthCached(params?: {
maxAgeMs?: number;
}): Promise<ResolvedMiniMaxAuth> {
const auth = await readAuthFileCached({
maxAgeMs: Math.max(0, params?.maxAgeMs ?? DEFAULT_MINIMAX_AUTH_CACHE_MAX_AGE_MS),
});
return resolveMiniMaxAuth(auth);
}
2 changes: 2 additions & 0 deletions src/lib/provider-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly<Record<string, string>> = {
"alibaba-coding-plan": "Alibaba Coding Plan",
zai: "Z.ai",
nanogpt: "NanoGPT",
"minimax-coding-plan": "MiniMax Coding Plan",
};

export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly<Record<string, string>> = {
Expand All @@ -24,6 +25,7 @@ export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly<Record<string, string>> = {
qwen: "qwen-code",
alibaba: "alibaba-coding-plan",
"nano-gpt": "nanogpt",
minimax: "minimax-coding-plan",
};

export function normalizeQuotaProviderId(id: string): string {
Expand Down
42 changes: 42 additions & 0 deletions src/lib/quota-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
resolveAlibabaCodingPlanAuth,
} from "./alibaba-auth.js";
import { hasQwenOAuthAuth, resolveQwenLocalPlan } from "./qwen-auth.js";
import {
DEFAULT_MINIMAX_AUTH_CACHE_MAX_AGE_MS,
resolveMiniMaxAuthCached,
} from "./minimax-auth.js";
import {
getPricingSnapshotHealth,
getPricingRefreshPolicy,
Expand Down Expand Up @@ -51,6 +55,7 @@ import {
getEffectiveCursorIncludedApiUsd,
} from "./cursor-pricing.js";
import type { CursorQuotaPlan, PricingSnapshotSource } from "./types.js";
import { queryMiniMaxQuota } from "../providers/minimax-coding-plan.js";

/** Session token fetch error info for status report */
export interface SessionTokenError {
Expand Down Expand Up @@ -475,7 +480,42 @@ export async function buildQuotaStatusReport(params: {
lines.push(`- alibaba coding plan error: ${alibabaCodingPlanAuth.error}`);
}

lines.push("");
lines.push("minimax:");
const minimaxAuth = await resolveMiniMaxAuthCached({
maxAgeMs: DEFAULT_MINIMAX_AUTH_CACHE_MAX_AGE_MS,
});
lines.push(`- auth_state: ${minimaxAuth.state}`);
lines.push(`- api_key_configured: ${minimaxAuth.state === "configured" ? "true" : "false"}`);
if (minimaxAuth.state === "invalid") {
lines.push(`- auth_error: ${sanitizeDisplayText(minimaxAuth.error)}`);
}
if (minimaxAuth.state === "configured") {
const minimaxQuota = await queryMiniMaxQuota(minimaxAuth.apiKey);
if (!minimaxQuota.success) {
lines.push(`- live_fetch_error: ${minimaxQuota.error}`);
} else {
const fiveHourEntry = minimaxQuota.entries.find((entry) => entry.window === "five_hour");
const weeklyEntry = minimaxQuota.entries.find((entry) => entry.window === "weekly");
if (fiveHourEntry) {
lines.push(
`- five_hour_usage: ${fiveHourEntry.right ?? "(none)"} percent_remaining=${fiveHourEntry.percentRemaining} reset_at=${fiveHourEntry.resetTimeIso ?? "(none)"}`,
);
}
if (weeklyEntry) {
lines.push(
`- weekly_usage: ${weeklyEntry.right ?? "(none)"} percent_remaining=${weeklyEntry.percentRemaining} reset_at=${weeklyEntry.resetTimeIso ?? "(none)"}`,
);
}
if (!fiveHourEntry && !weeklyEntry) {
lines.push("- live_state: no reportable MiniMax Coding Plan quota");
}
}
}

// Firmware API key diagnostics
lines.push("");
lines.push("firmware:");
let firmwareDiag: { configured: boolean; source: string | null; checkedPaths: string[] } = {
configured: false,
source: null,
Expand All @@ -491,6 +531,8 @@ export async function buildQuotaStatusReport(params: {
);

// Chutes API key diagnostics
lines.push("");
lines.push("chutes:");
let chutesDiag: { configured: boolean; source: string | null; checkedPaths: string[] } = {
configured: false,
source: null,
Expand Down
24 changes: 24 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ export interface NanoGptAuthData {
key: string;
}

export interface MiniMaxAuthData {
type: string;
key?: string;
access?: string;
}

/**
* Copilot subscription tier.
* See: https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot
Expand Down Expand Up @@ -268,6 +274,7 @@ export interface AuthData {
type: "api";
key: string;
};
"minimax-coding-plan"?: MiniMaxAuthData;
}

// =============================================================================
Expand Down Expand Up @@ -443,6 +450,23 @@ export type CopilotResult =
| null;
export type GoogleResult = GoogleQuotaResult | QuotaError | null;
export type ZaiResult = ZaiQuotaResult | QuotaError | null;
/** Single entry in a MiniMax quota result */
export interface MiniMaxResultEntry {
window: "five_hour" | "weekly";
name: string;
group?: string;
label?: string;
right?: string;
percentRemaining: number;
resetTimeIso?: string;
}

export type MiniMaxResult =
| {
success: true;
entries: MiniMaxResultEntry[];
}
| QuotaError;
export type ChutesResult =
| {
success: true;
Expand Down
Loading