mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
fix(usage): follow the CLIs' current auth locations for MiniMax and Z.ai
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/<buildEnv>/<region>/<clientId>/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.
This commit is contained in:
@@ -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/<buildEnv>/<region>/<clientId>/`.
|
||||
*/
|
||||
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<string, () => 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<string, string> | 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: [
|
||||
|
||||
@@ -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/<env>/<region>/<clientId>/`.
|
||||
* 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<typeof MiniMaxModelRemainSchema>;
|
||||
|
||||
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/<buildEnv>/<region>/<clientId>/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<z.infer<typeof MiniMaxOAuthStateSchema> | 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/<buildEnv>/<region>/<clientId>/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<UsageInput[]> {
|
||||
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<UsageAccount[]> {
|
||||
const candidates: UsageInput[] = [
|
||||
{ store: "env", locator: "MINIMAX_API_KEY" },
|
||||
@@ -248,5 +375,14 @@ export async function discover(): Promise<UsageAccount[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<typeof inputSchema>;
|
||||
|
||||
@@ -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<string, string> | 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<string, string> | 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<string, string> | 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 () => {
|
||||
|
||||
@@ -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<UsageReport> {
|
||||
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<string | undefined> {
|
||||
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<UsageAccount[]> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user