From 7ba608fcb23849bdc20f7b764200977cd5fe0ea7 Mon Sep 17 00:00:00 2001 From: peanut_hzm Date: Sat, 31 Oct 2026 00:39:47 +0800 Subject: [PATCH] fix(usage): follow the CLIs' current auth locations for MiniMax and Z.ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage sources look for credentials where the CLIs used to keep them, so their plan quota never appears in Settings → Usage. MiniMax Code (`mcode`) moved its OAuth login to `~/.minimax/auth////auth.json`, with the token inside a `records` map and the region in a sibling `auth-state.json`. The source still looked in `~/.mmx/` for the older `access_token` shape. It also called `/v1/token_plan/remains`, which the agent backend answers with `status_code 1004` ("Please carry the API secret key in the 'Authorization' field"); the coding-plan endpoint the CLI itself uses is `/v1/api/openplatform/coding_plan/remains`. Z.ai's `glm-acp-agent` reads `Z_AI_API_KEY` and persists it to `~/.config/glm-acp-agent/credentials.json` as `z_ai_api_key`. The source looked for `ZAI_API_KEY` and `GLM_API_KEY` only, and never read the credentials file. Both keep their existing lookups working, so a configured environment variable or the older `~/.mmx/` layout still resolves. --- .../minimax-usage-source/server/usage.test.ts | 152 +++++++++++++++++- plugins/minimax-usage-source/server/usage.ts | 140 +++++++++++++++- plugins/minimax-usage-source/shared/input.ts | 7 +- plugins/zai-usage-source/server/usage.test.ts | 56 ++++++- plugins/zai-usage-source/server/usage.ts | 39 ++++- plugins/zai-usage-source/shared/input.ts | 2 +- 6 files changed, 382 insertions(+), 14 deletions(-) diff --git a/plugins/minimax-usage-source/server/usage.test.ts b/plugins/minimax-usage-source/server/usage.test.ts index 57b58418ac..7450399c3b 100644 --- a/plugins/minimax-usage-source/server/usage.test.ts +++ b/plugins/minimax-usage-source/server/usage.test.ts @@ -23,6 +23,60 @@ function writeMiniMaxCredentials( writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); } +/** + * Writes the OAuth login the `mcode` CLI keeps under + * `~/.minimax/auth////`. + */ +function writeMiniMaxOAuthLogin( + dir: string, + options: { + buildEnv?: string; + region?: string; + clientId?: string; + accessToken?: string; + expiresAtMs?: number; + } = {}, +): string { + const buildEnv = options.buildEnv ?? "prod"; + const region = options.region ?? "cn"; + const clientId = options.clientId ?? "mcode-public"; + const clientDir = join(dir, ".minimax", "auth", buildEnv, region, clientId); + mkdirSync(clientDir, { recursive: true }); + writeFileSync( + join(clientDir, "auth.json"), + JSON.stringify({ + schemaVersion: 1, + records: { + "com.minimax.mcode.oauth.prod.cn": { + schemaVersion: 1, + accessToken: options.accessToken ?? "mmoat_fixture_token", + refreshToken: "mmort_fixture_token", + tokenType: "Bearer", + clientId, + scopes: ["agent.default"], + audience: "agent-backend", + ...(options.expiresAtMs === undefined ? {} : { expiresAtMs: options.expiresAtMs }), + }, + }, + }), + ); + writeFileSync( + join(clientDir, "auth-state.json"), + JSON.stringify({ + schemaVersion: 2, + status: "authenticated", + storeKind: "file", + clientId, + scopes: ["agent.default"], + audience: "agent-backend", + buildEnv, + region, + ...(options.expiresAtMs === undefined ? {} : { expiresAtMs: options.expiresAtMs }), + }), + ); + return join(clientDir, "auth.json"); +} + function mockFetch(handlers: Map Response>): typeof fetch { return vi.fn(async (url: RequestInfo | URL) => { const key = url.toString(); @@ -131,7 +185,7 @@ describe("minimax usage source", () => { const miniMax = findProvider(await service().listUsage(), "minimax"); - expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); + expect(requestedUrl).toBe("https://api.minimax.io/v1/api/openplatform/coding_plan/remains"); expect(authorization).toBe("Bearer minimax_test_token"); expect(miniMax).toMatchObject({ status: "available", @@ -158,6 +212,88 @@ describe("minimax usage source", () => { expect(await discover()).toEqual([]); }); + it("discovers the OAuth login the mcode CLI stores under ~/.minimax/auth", async () => { + const authPath = writeMiniMaxOAuthLogin(homeDir, { + accessToken: "mmoat_fixture_token", + }); + + const accounts = await discover(); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.input).toEqual({ + store: "oauth", + locator: authPath, + baseUrl: "https://api.minimaxi.com", + }); + }); + + it("uses the region host the CLI logged in against", async () => { + writeMiniMaxOAuthLogin(homeDir, { region: "en" }); + + const accounts = await discover(); + + expect(accounts[0]?.input).toMatchObject({ baseUrl: "https://api.minimax.io" }); + }); + + it("fetches MiniMax usage from the CLI OAuth login", async () => { + writeMiniMaxOAuthLogin(homeDir, { accessToken: "mmoat_fixture_token" }); + let requestedUrl: string | null = null; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + model_remains: [ + { + model_name: "general", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_remaining_percent: 87, + current_weekly_remaining_percent: 78, + }, + ], + }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"); + expect(authorization).toBe("Bearer mmoat_fixture_token"); + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "interval_general", usedPct: 13, remainingPct: 87 }), + expect.objectContaining({ id: "weekly_general", usedPct: 22, remainingPct: 78 }), + ]), + }); + }); + + it("reports an expired CLI OAuth login as expired", async () => { + writeMiniMaxOAuthLogin(homeDir, { + accessToken: "mmoat_fixture_token", + expiresAtMs: Date.now() - 60_000, + }); + let requested = false; + fetchApi = (async () => { + requested = true; + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requested).toBe(false); + expect(miniMax).toMatchObject({ + status: "unavailable", + problem: { kind: "expired" }, + }); + }); + + it("ignores a CLI auth directory that has no access token", async () => { + writeMiniMaxOAuthLogin(homeDir, { accessToken: "" }); + + expect(await discover()).toEqual([]); + }); + it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { writeMiniMaxCredentials( homeDir, @@ -173,7 +309,9 @@ describe("minimax usage source", () => { await service().listUsage(); - expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); + expect(requestedUrl).toBe( + "https://account.example.com/v1/api/openplatform/coding_plan/remains", + ); }); it("falls back to MiniMax api_key in the CLI config file", async () => { @@ -189,7 +327,7 @@ describe("minimax usage source", () => { const miniMax = findProvider(await service().listUsage(), "minimax"); - expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"); expect(miniMax.status).toBe("unavailable"); }); @@ -198,7 +336,7 @@ describe("minimax usage source", () => { fetchApi = mockFetch( new Map([ [ - "https://api.minimaxi.com/v1/token_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", () => jsonResponse({ model_remains: null, @@ -222,7 +360,7 @@ describe("minimax usage source", () => { fetchApi = mockFetch( new Map([ [ - "https://api.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", () => jsonResponse({ model_remains: null }), ], ]), @@ -242,7 +380,7 @@ describe("minimax usage source", () => { fetchApi = mockFetch( new Map([ [ - "https://api.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", () => jsonResponse({ base_resp: { status_code: 0, status_msg: "success" }, @@ -275,7 +413,7 @@ describe("minimax usage source", () => { fetchApi = mockFetch( new Map([ [ - "https://api.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", () => jsonResponse({ model_remains: [ diff --git a/plugins/minimax-usage-source/server/usage.ts b/plugins/minimax-usage-source/server/usage.ts index 7bf602cff1..92d483d7db 100644 --- a/plugins/minimax-usage-source/server/usage.ts +++ b/plugins/minimax-usage-source/server/usage.ts @@ -1,7 +1,7 @@ import type { UsageInput } from "../shared/input.js"; import { promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { z } from "zod"; import { unavailable, @@ -20,6 +20,10 @@ const ApiOptionalStringSchema = z.preprocess( const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; +// The coding-plan quota endpoint the CLI itself calls. The older +// `/v1/token_plan/remains` path is not the one the agent backend serves. +const MINIMAX_CODING_PLAN_PATH = "/v1/api/openplatform/coding_plan/remains"; + const MiniMaxModelRemainSchema = z.object({ model_name: ApiOptionalStringSchema, start_time: ApiNumberSchema.optional(), @@ -68,6 +72,31 @@ const MiniMaxConfigSchema = z.object({ oauth: MiniMaxCredentialsSchema.optional(), }); +/** + * The `mcode` CLI stores its OAuth login under `~/.minimax/auth////`. + * Each client directory holds an `auth.json` with the token and an + * `auth-state.json` with the region and expiry. + */ +const MiniMaxOAuthAuthSchema = z.object({ + schemaVersion: z.number().optional(), + records: z.record( + z.string(), + z.object({ + accessToken: z.string().optional(), + refreshToken: z.string().optional(), + expiresAtMs: z.number().optional(), + }), + ), +}); + +const MiniMaxOAuthStateSchema = z.object({ + status: z.string().optional(), + clientId: z.string().optional(), + buildEnv: z.string().optional(), + region: z.string().optional(), + expiresAtMs: z.number().optional(), +}); + type MiniMaxModelRemain = z.infer; interface MiniMaxResolvedAuth { @@ -154,7 +183,7 @@ export async function fetchUsage( if (auth.expiresAt && Date.parse(auth.expiresAt) <= Date.now()) return unavailable({ kind: "expired", expiresAt: new Date(auth.expiresAt).toISOString() }); - const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, { + const res = await fetchApi(`${auth.baseUrl}${MINIMAX_CODING_PLAN_PATH}`, { signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${auth.token}`, @@ -207,6 +236,9 @@ async function readAuth( ? { token, baseUrl: resolveBaseUrl({ baseUrl: process.env.MINIMAX_BASE_URL }) } : undefined; } + if (input.store === "oauth") { + return readOAuthAuth(input); + } try { const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8")); if (input.store === "credentials") { @@ -239,6 +271,101 @@ async function readAuth( return undefined; } } + +/** + * Reads the OAuth login the `mcode` CLI writes to + * `~/.minimax/auth////auth.json`. The region + * decides which host serves the account, and the token lives inside a + * `records` map keyed by the OAuth grant. + */ +async function readOAuthAuth( + input: UsageInput, +): Promise<(MiniMaxResolvedAuth & { expiresAt?: string }) | undefined> { + try { + const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8")); + const auth = MiniMaxOAuthAuthSchema.parse(raw); + const record = Object.values(auth.records).find((entry) => entry.accessToken); + if (!record?.accessToken) return undefined; + + const state = await readOAuthState(input.locator); + const expiresAtMs = record.expiresAtMs ?? state?.expiresAtMs; + return { + token: record.accessToken, + baseUrl: resolveBaseUrl({ + baseUrl: input.baseUrl, + region: state?.region, + }), + expiresAt: typeof expiresAtMs === "number" ? new Date(expiresAtMs).toISOString() : undefined, + }; + } catch { + return undefined; + } +} + +async function readOAuthState( + authPath: string, +): Promise | undefined> { + try { + const statePath = join(dirname(authPath), "auth-state.json"); + const raw: unknown = JSON.parse(await fs.readFile(statePath, "utf8")); + return MiniMaxOAuthStateSchema.parse(raw); + } catch { + return undefined; + } +} +/** + * Locates the `mcode` CLI's OAuth login. The layout is + * `~/.minimax/auth////auth.json`, so the client + * directory is found by scanning the region directories rather than by + * assuming a fixed client id. + */ +async function discoverOAuthLogins(): Promise { + const authRoot = join(homedir(), ".minimax", "auth"); + const inputs: UsageInput[] = []; + let buildEnvDirs: string[]; + try { + buildEnvDirs = (await fs.readdir(authRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return inputs; + } + + for (const buildEnv of buildEnvDirs) { + const envRoot = join(authRoot, buildEnv); + let regionDirs: string[]; + try { + regionDirs = (await fs.readdir(envRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + continue; + } + for (const region of regionDirs) { + const regionRoot = join(envRoot, region); + let clientDirs: string[]; + try { + clientDirs = (await fs.readdir(regionRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + continue; + } + for (const clientId of clientDirs) { + const authPath = join(regionRoot, clientId, "auth.json"); + const auth = await readOAuthAuth({ store: "oauth", locator: authPath }); + if (!auth) continue; + inputs.push({ + store: "oauth", + locator: authPath, + baseUrl: auth.baseUrl, + }); + } + } + } + return inputs; +} + export async function discover(): Promise { const candidates: UsageInput[] = [ { store: "env", locator: "MINIMAX_API_KEY" }, @@ -248,5 +375,14 @@ export async function discover(): Promise { const accounts: UsageAccount[] = []; for (const input of candidates) if (await readAuth(input)) accounts.push({ key: "default", input }); + if (accounts.length > 0) return accounts; + + const oauthInputs = await discoverOAuthLogins(); + for (const input of oauthInputs) { + if (await readAuth(input)) { + accounts.push({ key: "default", input }); + break; + } + } return accounts; } diff --git a/plugins/minimax-usage-source/shared/input.ts b/plugins/minimax-usage-source/shared/input.ts index 22e12a603f..4bee83584e 100644 --- a/plugins/minimax-usage-source/shared/input.ts +++ b/plugins/minimax-usage-source/shared/input.ts @@ -1,8 +1,13 @@ import { z } from "zod"; export const inputSchema = z .object({ - store: z.enum(["env", "credentials", "config"]), + store: z.enum(["env", "credentials", "config", "oauth"]), locator: z.string().min(1), + /** + * Base URL for the coding-plan quota endpoint. The CLI's OAuth login is + * region-scoped (cn vs en), and each region serves its own host. + */ + baseUrl: z.string().optional(), }) .strict(); export type UsageInput = z.infer; diff --git a/plugins/zai-usage-source/server/usage.test.ts b/plugins/zai-usage-source/server/usage.test.ts index d63602cd73..1c9a940787 100644 --- a/plugins/zai-usage-source/server/usage.test.ts +++ b/plugins/zai-usage-source/server/usage.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,6 +37,7 @@ describe("zai usage source", () => { "GITHUB_PAT", "CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN", + "Z_AI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY", "GROK_API_KEY", @@ -116,6 +117,59 @@ describe("zai usage source", () => { details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]), }); }); + + it("fetches Z.ai usage from Z_AI_API_KEY, the name the glm-acp-agent CLI uses", async () => { + process.env["Z_AI_API_KEY"] = "zai_cli_token"; + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] }); + }) as unknown as typeof fetch; + + const zai = findProvider(await service().listUsage(), "zai"); + + expect(authorization).toBe("Bearer zai_cli_token"); + expect(zai).toMatchObject({ status: "available", planLabel: "GLM Coding Max" }); + }); + + it("reads Z.ai usage from the credentials file the glm-acp-agent CLI writes", async () => { + const configDir = join(homeDir, ".config", "glm-acp-agent"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "credentials.json"), + JSON.stringify({ z_ai_api_key: "zai_file_token" }), + ); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] }); + }) as unknown as typeof fetch; + + const zai = findProvider(await service().listUsage(), "zai"); + + expect(authorization).toBe("Bearer zai_file_token"); + expect(zai).toMatchObject({ status: "available" }); + }); + + it("honours XDG_CONFIG_HOME for the credentials file", async () => { + const configDir = join(homeDir, "xdg", "glm-acp-agent"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "credentials.json"), + JSON.stringify({ z_ai_api_key: "zai_xdg_token" }), + ); + process.env["XDG_CONFIG_HOME"] = join(homeDir, "xdg"); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] }); + }) as unknown as typeof fetch; + + const zai = findProvider(await service().listUsage(), "zai"); + + expect(authorization).toBe("Bearer zai_xdg_token"); + expect(zai).toMatchObject({ status: "available" }); + }); }); it("discovery returns a locator when fetch finds zai credentials", async () => { diff --git a/plugins/zai-usage-source/server/usage.ts b/plugins/zai-usage-source/server/usage.ts index 9c07fa6d4e..4a5e95c16b 100644 --- a/plugins/zai-usage-source/server/usage.ts +++ b/plugins/zai-usage-source/server/usage.ts @@ -1,4 +1,7 @@ import type { UsageInput } from "../shared/input.js"; +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { z } from "zod"; import { unavailable, @@ -25,11 +28,16 @@ const ZaiUsageResponseSchema = z.object({ .optional(), }); +/** The credentials file the `glm-acp-agent` CLI writes. */ +const ZaiCredentialsSchema = z.object({ + z_ai_api_key: z.string().optional(), +}); + export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - const token = process.env[input.locator]; + const token = await readToken(input); if (!token) throw new Error("Z.ai login store no longer exists"); const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", { @@ -64,9 +72,36 @@ export async function fetchUsage( }; } +/** + * The Z.AI API key comes from the environment or from the credentials file the + * `glm-acp-agent` CLI writes. The CLI names the variable `Z_AI_API_KEY`; the + * older `ZAI_API_KEY` spelling is still accepted. + */ +async function readToken(input: UsageInput): Promise { + if (input.store === "env") { + return process.env[input.locator]; + } + try { + const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8")); + return ZaiCredentialsSchema.parse(raw).z_ai_api_key; + } catch { + return undefined; + } +} + export async function discover(): Promise { - for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) { + for (const locator of ["Z_AI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY"]) { if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }]; } + + const credentialsPath = join( + process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), + "glm-acp-agent", + "credentials.json", + ); + if (await readToken({ store: "file", locator: credentialsPath })) { + return [{ key: "default", input: { store: "file", locator: credentialsPath } }]; + } + return []; } diff --git a/plugins/zai-usage-source/shared/input.ts b/plugins/zai-usage-source/shared/input.ts index 0d34ddf4e4..4be5cce316 100644 --- a/plugins/zai-usage-source/shared/input.ts +++ b/plugins/zai-usage-source/shared/input.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const inputSchema = z .object({ - store: z.literal("env"), + store: z.enum(["env", "file"]), locator: z.string().min(1), }) .strict();