feat(glm): add Z.ai OAuth login to GLM Coding (dual-auth)

OAuth login via the ZCode CLI polling flow: init -> browser authorize ->
poll ready -> business JWT -> auto-mint a coding-plan API key stored on
accessToken. Pasted-apikey mode is unchanged; quota works for both.
No refresh grant upstream, so expiry means re-login. Display renamed to
"Zai GLM Coding".
This commit is contained in:
Feavy committed 2026-10-01 10:03:35 +07:00
1 parent 6b9dc54dd2
commit 068ce87d20
11 files changed
+635 -37

No files matched your search

+3 -2
View File
@@ -138,11 +138,12 @@ const OAUTH_PROVIDERS = {
iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
glm: { id: "glm", alias: "glm", name: "Zai GLM Coding" },
};
const APIKEY_PROVIDERS = {
openrouter: { id: "openrouter", name: "OpenRouter" },
glm: { id: "glm", name: "GLM Coding" },
glm: { id: "glm", name: "Zai GLM Coding" },
minimax: { id: "minimax", name: "Minimax Coding" },
kimi: { id: "kimi", name: "Kimi" },
openai: { id: "openai", name: "OpenAI" },
@@ -399,7 +400,7 @@ async function showConnectionActions(connection, providerId, breadcrumb = []) {
* @param {string} authType - "oauth" or "apikey"
*/
// Providers that use Device Code Flow (terminal-based polling)
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro", "glm"];
/**
* Handle adding new connection - auto-detect flow type
+1 -1
View File
@@ -21,7 +21,7 @@ const PROVIDER_ALIAS_NAMES = {
oc: "OpenCode Free",
opencode: "OpenCode Free",
openrouter: "OpenRouter",
glm: "GLM Coding",
glm: "Zai GLM Coding",
kimi: "Kimi Coding",
minimax: "Minimax Coding",
openai: "OpenAI",
+20 -2
View File
@@ -5,16 +5,34 @@ export default {
priority: 140,
alias: "glm",
display: {
name: "GLM Coding",
name: "Zai GLM Coding",
icon: "code",
color: "#2563EB",
textIcon: "GL",
website: "https://open.bigmodel.cn",
notice: {
apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys",
signupUrl: "https://chat.z.ai",
},
},
category: "apikey",
category: "oauth",
// Dual-auth like kimi: paste an API key, or OAuth-login with the Z.ai
// account to auto-mint a coding-plan key.
authModes: ["oauth", "apikey"],
hasOAuth: true,
// OAuth = ZCode CLI polling flow (apps/zcode-cli cli-oauth.ts) — no PKCE, no
// local callback: init mints a one-off poll token, the browser authorize_url
// is server-generated, and poll/ready carries the tokens. The Z.AI OAuth
// token is exchanged for a business JWT, then a long-lived coding-plan API
// key (no refresh grant — re-login on expiry, same as the official CLI).
oauth: {
providerId: "zai",
cliInitUrl: "https://zcode.z.ai/api/v1/oauth/cli/init",
cliPollUrl: "https://zcode.z.ai/api/v1/oauth/cli/poll",
businessLoginUrl: "https://api.z.ai/api/auth/z/login",
apiBaseUrl: "https://api.z.ai",
planApiKeyName: "zcode-api-key",
},
transport: {
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
format: "claude",
+3 -2
View File
@@ -46,8 +46,9 @@ const USAGE_HANDLERS = {
"qoder-cn": (c) => getQoderUsageFor(c),
iflow: (c) => getIflowUsage(c.accessToken),
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
"glm-cn": (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
// OAuth connections store the coding-plan key on accessToken (no apiKey)
glm: (c) => getGlmUsage(c.apiKey || c.accessToken, c.provider, c.proxyOptions),
"glm-cn": (c) => getGlmUsage(c.apiKey || c.accessToken, c.provider, c.proxyOptions),
minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
+38 -29
View File
@@ -12,37 +12,11 @@ const GLM_QUOTA_URLS = {
};
/**
* GLM Coding Plan usage (international + China regions)
* Parse the GLM quota API response — shared by pasted API keys and
* OAuth-minted coding-plan keys (both hit the same monitor endpoint).
* Supports both TOKENS_LIMIT and CREDIT_LIMIT and dynamic intervals (e.g. session 5h, weekly 7d).
*/
export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
if (!apiKey) {
return { message: "GLM API key not available." };
}
const region = provider === "glm-cn" ? "china" : "international";
const quotaUrl = GLM_QUOTA_URLS[region];
try {
const response = await proxyAwareFetch(
quotaUrl,
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
},
proxyOptions,
);
if (!response.ok) {
if (response.status === 401) {
return { message: "GLM API key invalid or expired." };
}
return { message: `GLM quota API error (${response.status}).` };
}
const json = await response.json();
export function parseGlmQuotaResponse(json) {
const data = json?.data && typeof json.data === "object" ? json.data : {};
const limits = Array.isArray(data.limits) ? data.limits : [];
const quotas = {};
@@ -82,6 +56,41 @@ export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
: "Unknown";
return { plan, quotas };
}
/**
* GLM Coding Plan usage (international + China regions)
*/
export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
if (!apiKey) {
return { message: "GLM API key not available." };
}
const region = provider === "glm-cn" ? "china" : "international";
const quotaUrl = GLM_QUOTA_URLS[region];
try {
const response = await proxyAwareFetch(
quotaUrl,
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
},
proxyOptions,
);
if (!response.ok) {
if (response.status === 401) {
return { message: "GLM API key invalid or expired." };
}
return { message: `GLM quota API error (${response.status}).` };
}
const json = await response.json();
const { plan, quotas } = parseGlmQuotaResponse(json);
return { plan, quotas };
} catch (error) {
return { message: `GLM error: ${error.message}` };
}
@@ -266,6 +266,7 @@ export async function GET(request, { params }) {
"qoder-cn",
"grok-cli",
"muse",
"glm",
];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
@@ -499,7 +500,7 @@ export async function POST(request, { params }) {
}
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"];
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl", "glm"];
let result;
if (noPkceProviders.includes(provider)) {
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)
+8
View File
@@ -205,6 +205,13 @@ export const WINDSURF_CONFIG = {
oauthTimeoutMs: 600_000,
};
// GLM Coding (Z.ai) OAuth — ZCode CLI polling flow (NOT PKCE): init mints a
// one-off poll token, the browser opens the server-generated authorize_url,
// poll/ready returns the tokens. The Z.AI OAuth token is then exchanged for a
// platform business JWT and finally a long-lived coding-plan API key (no
// refresh grant).
export const GLM_OAUTH_CONFIG = { ...PROVIDER_OAUTH["glm"] };
// Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth).
// Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin;
// Zed redirects to local callback with access_token RSA-encrypted against our public key.
@@ -245,5 +252,6 @@ export const PROVIDERS = {
GROK_CLI: "grok-cli",
TRAE: "trae",
WINDSURF: "windsurf",
GLM: "glm",
ZED: "zed",
};
+305
View File
@@ -0,0 +1,305 @@
import crypto from "crypto";
import { GLM_OAUTH_CONFIG } from "../constants/oauth.js";
// Zai GLM Coding OAuth — CLI polling flow (mirrors the official
// ZCode CLI, apps/zcode-cli packages/adapters/src/auth/cli-oauth.ts +
// coding-plan-api-key.ts). No PKCE and no local callback server:
//
// 1) POST {cliInitUrl} Authorization: Bearer <pollToken> {"provider":"zai"}
// → { code: 0, data: { authorize_url, flow_id, poll_interval_sec, expires_at } }
// 2) Browser opens authorize_url; user signs in with the Z.ai account
// 3) GET {cliPollUrl}/<flow_id> Authorization: Bearer <pollToken>
// → { data: { status: "pending" } } until
// { data: { status: "ready", token, user, accessToken, refreshToken? } }
// 4) accessToken (Z.AI OAuth token) → POST {businessLoginUrl} {"token": ...}
// → { data: { access_token } } (platform business JWT)
// 5) Business JWT → coding-plan API key via getCustomerInfo → api_keys
// list/create("zcode-api-key") → copy → "apiKey.secretKey"
//
// The coding-plan API key is the long-lived model credential; the ZAI OAuth
// provider has no refresh_token grant, so expiry means re-login (same as the
// official CLI). zcode JWT + business token ride along in providerSpecificData
// for quota/usage and debugging.
const glm = {
config: GLM_OAUTH_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const pollToken = crypto.randomBytes(32).toString("hex");
const response = await fetch(config.cliInitUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${pollToken}`,
},
body: JSON.stringify({ provider: config.providerId || "zai" }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`ZCode OAuth init failed: ${error}`);
}
const payload = await response.json();
if (!isSuccessCode(payload.code) || !payload.data) {
throw new Error(payload.msg || "ZCode OAuth init returned no data");
}
const data = payload.data;
if (!data.flow_id || !data.authorize_url) {
throw new Error("ZCode OAuth init response missing flow_id/authorize_url");
}
return {
device_code: data.flow_id,
verification_uri: data.authorize_url,
// expires_at is upstream-absolute; surface a relative deadline for the UI
expires_in: relativeSeconds(data.expires_at) ?? 300,
interval: data.poll_interval_sec || 3,
_zcodePollToken: pollToken,
};
},
pollToken: async (config, deviceCode, _codeVerifier, extraData) => {
const pollToken = extraData?._zcodePollToken;
if (!pollToken) {
return {
ok: true,
data: {
error: "access_denied",
error_description: "Missing ZCode poll token — restart the login flow",
},
};
}
const response = await fetch(`${config.cliPollUrl}/${encodeURIComponent(deviceCode)}`, {
headers: { Authorization: `Bearer ${pollToken}` },
});
if (!response.ok) {
return {
ok: true,
data: {
error: "access_denied",
error_description: `ZCode poll failed (HTTP ${response.status})`,
},
};
}
const payload = await response.json();
if (!isSuccessCode(payload.code)) {
return {
ok: true,
data: { error: "access_denied", error_description: payload.msg || "ZCode poll failed" },
};
}
const data = payload.data || {};
if (data.status === "pending") {
return { ok: true, data: { error: "authorization_pending" } };
}
if (data.status === "failed") {
return {
ok: true,
data: {
error: "access_denied",
error_description: "ZCode authorization failed or was cancelled",
},
};
}
if (data.status !== "ready") {
return {
ok: true,
data: { error: "authorization_pending", error_description: `Unknown status: ${data.status}` },
};
}
// ready payload nests the ZAI OAuth tokens under data[providerId] (see
// apps/zcode-cli cli-oauth.ts parseReadyData): { status:"ready", token,
// user, zai: { access_token, refresh_token? } }. Fall back to top-level
// fields for resilience against payload drift.
const providerData = data[config.providerId] || data[data.providerId] || {};
const zaiAccessToken =
providerData.access_token ||
providerData.accessToken ||
data.accessToken ||
data.access_token;
if (!zaiAccessToken) {
return {
ok: true,
data: {
error: "access_denied",
error_description: "ZCode poll response missing access token",
},
};
}
// ready.accessToken is the Z.AI OAuth token — derive the coding-plan API key
const { planApiKey, businessToken } = await resolveCodingPlanApiKey(config, zaiAccessToken);
return {
ok: true,
data: {
access_token: planApiKey,
_zcodeJwtToken: data.token || "",
_zaiBusinessToken: businessToken,
_zaiRefreshToken:
providerData.refresh_token || providerData.refreshToken || data.refresh_token || data.refreshToken || "",
_zcodeUser: data.user || {},
},
};
},
mapTokens: (tokens) => {
const user = tokens._zcodeUser || {};
const displayName = user.name || user.email || null;
return {
accessToken: tokens.access_token,
refreshToken: null,
email: user.email || null,
...(displayName ? { displayName } : {}),
providerSpecificData: {
authMethod: "cli_poll",
username: user.name || undefined,
userId: user.user_id || undefined,
zcodeJwtToken: tokens._zcodeJwtToken || undefined,
zaiBusinessToken: tokens._zaiBusinessToken || undefined,
...(tokens._zaiRefreshToken ? { zaiRefreshToken: tokens._zaiRefreshToken } : {}),
},
};
},
};
// Business JWT → coding-plan API key ("apiKey.secretKey"). Mirrors ZCode CLI
// coding-plan-api-key.ts: getCustomerInfo → default org/project → api_keys
// list/create("zcode-api-key") → copy → secretKey.
async function resolveCodingPlanApiKey(config, zaiAccessToken) {
const businessToken = await exchangeBusinessToken(config, zaiAccessToken);
const authHeaders = {
Authorization: `Bearer ${businessToken}`,
"Content-Type": "application/json",
};
const customerInfo = await fetchBusinessJson(
`${config.apiBaseUrl}/api/biz/customer/getCustomerInfo`,
{ headers: authHeaders },
"customer info"
);
const location = pickOrgAndProject(customerInfo);
if (!location) {
throw new Error("Unable to resolve Z.ai organization and project for the coding plan");
}
const listUrl =
`${config.apiBaseUrl}/api/biz/v1/organization/${location.organizationId}` +
`/projects/${location.projectId}/api_keys`;
const keys = (await fetchBusinessJson(listUrl, { headers: authHeaders }, "api keys")) || [];
let keyEntry = Array.isArray(keys)
? keys.find((item) => item?.name === config.planApiKeyName)
: null;
if (!keyEntry) {
keyEntry = await fetchBusinessJson(
listUrl,
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({ name: config.planApiKeyName }),
},
"api key create"
);
}
const apiKey = keyEntry?.apiKey?.trim();
if (!apiKey) {
throw new Error("Z.ai api_keys response is missing apiKey");
}
const secret = await fetchBusinessJson(
`${listUrl}/copy/${encodeURIComponent(apiKey)}`,
{ headers: authHeaders },
"api key copy"
);
const secretKey = secret?.secretKey?.trim();
if (!secretKey) {
throw new Error("Z.ai api key copy response is missing secretKey");
}
return { planApiKey: `${apiKey}.${secretKey}`, businessToken };
}
// POST {businessLoginUrl} {"token": <zai oauth token>} → { data: { access_token } }
async function exchangeBusinessToken(config, zaiAccessToken) {
const payload = await fetchBusinessJson(
config.businessLoginUrl,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: zaiAccessToken }),
},
"Z.ai business login"
);
const token = payload?.access_token?.trim() || payload?.accessToken?.trim();
if (!token) {
throw new Error("Z.ai business login response is missing access_token");
}
return token;
}
// Business endpoints answer {code, msg, data}; code 0/200 (or absent) = success.
// data is returned directly (null when missing).
async function fetchBusinessJson(url, options, label) {
const response = await fetch(url, options);
const text = await response.text();
if (!response.ok) {
throw new Error(`Z.ai ${label} request failed (HTTP ${response.status}): ${text.slice(0, 200)}`);
}
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`Z.ai ${label} response is not valid JSON`);
}
if (!isSuccessCode(payload?.code) || payload?.success === false) {
throw new Error(payload?.msg || `Z.ai ${label} returned business error ${payload?.code}`);
}
return payload?.data ?? payload ?? null;
}
// Prefer the org named "默认机构"/"default" and the non-team project named
// "默认项目"/"default" (projectType "2" = team), falling back to the first entries.
function pickOrgAndProject(customerInfo) {
const organizations = Array.isArray(customerInfo?.organizations)
? customerInfo.organizations
: [];
const personalOrgs = organizations
.map((organization) => ({
organization,
projects: (organization?.projects || []).filter(
(project) => String(project?.projectType ?? "").trim() !== "2"
),
}))
.filter(({ organization, projects }) =>
Boolean(organization?.organizationId && projects.length)
);
if (!personalOrgs.length) return null;
const org =
personalOrgs.find(({ organization }) => isDefaultName(organization.organizationName)) ||
personalOrgs[0];
const project =
org.projects.find((item) => isDefaultName(item?.projectName)) || org.projects[0];
if (!org.organization?.organizationId || !project?.projectId) return null;
return { organizationId: org.organization.organizationId, projectId: project.projectId };
}
function isDefaultName(name) {
const normalized = String(name || "").trim().toLowerCase();
return normalized.includes("默认机构") || normalized.includes("默认项目") || normalized === "default";
}
function isSuccessCode(code) {
return code === undefined || code === null || code === 0 || code === 200 || code === "0" || code === "200";
}
// Absolute epoch (s or ms) → seconds from now; null when absent/invalid.
function relativeSeconds(expiresAt) {
const raw = Number(expiresAt);
if (!Number.isFinite(raw) || raw <= 0) return null;
const ms = raw > 1e12 ? raw : raw * 1000;
const seconds = Math.floor((ms - Date.now()) / 1000);
return seconds > 0 ? seconds : null;
}
export default glm;
+2
View File
@@ -28,6 +28,7 @@ import kimchi from "./kimchi.js";
import trae from "./trae.js";
import windsurf from "./windsurf.js";
import zed from "./zed.js";
import glm from "./glm.js";
// Provider configurations
const PROVIDERS = {
@@ -55,6 +56,7 @@ const PROVIDERS = {
trae,
windsurf,
zed,
glm,
};
export { PROVIDERS };
+5
View File
@@ -292,6 +292,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
"qoder-cn",
"grok-cli",
"muse",
"glm",
];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
@@ -334,6 +335,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
: (provider === "kimi" || provider === "kimi-coding")
? { _kimiDeviceId: data._kimiDeviceId }
: provider === "glm"
? { _zcodePollToken: data._zcodePollToken }
: null;
startPolling(
data.device_code,
@@ -924,6 +927,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
</Button>
</div>
</div>
{deviceData.user_code && (
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">Your Code</p>
<div className="flex items-center justify-center gap-2">
@@ -936,6 +940,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
/>
</div>
</div>
)}
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// proxyAwareFetch captures globalThis.fetch at import time — mock the module
// (like kimi-usage.test.js) instead of stubbing global fetch for usage tests.
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch: vi.fn(),
default: vi.fn(),
}));
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
import glmOauthProvider from "../../src/lib/oauth/providers/glm.js";
import { getProvider } from "../../src/lib/oauth/providers";
import { PROVIDERS as TRANSPORTS, PROVIDER_OAUTH } from "../../open-sse/providers/index.js";
import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js";
import { getUsageForProvider } from "../../open-sse/services/usage.js";
import { DefaultExecutor } from "../../open-sse/executors/default.js";
const ANTHROPIC_URL = "https://api.z.ai/api/anthropic/v1/messages";
const PLAN_KEY = "key123.secret456";
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("glm registry entry (dual-auth)", () => {
it("is an oauth+apikey provider with usage enabled", () => {
expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm");
expect(PROVIDER_OAUTH.glm).toBeDefined();
});
it("keeps the direct api.z.ai anthropic transport (no separate gateway)", () => {
expect(TRANSPORTS.glm.baseUrl).toBe(ANTHROPIC_URL);
expect(TRANSPORTS.glm.auth.header).toBe("x-api-key");
// no zcode gateway/hook leftovers
expect(TRANSPORTS.zcode).toBeUndefined();
expect(PROVIDER_OAUTH.zcode).toBeUndefined();
});
it("declares the ZCode CLI polling OAuth endpoints (no refresh grant)", () => {
expect(PROVIDER_OAUTH.glm.cliInitUrl).toBe("https://zcode.z.ai/api/v1/oauth/cli/init");
expect(PROVIDER_OAUTH.glm.cliPollUrl).toBe("https://zcode.z.ai/api/v1/oauth/cli/poll");
expect(PROVIDER_OAUTH.glm.businessLoginUrl).toBe("https://api.z.ai/api/auth/z/login");
expect(PROVIDER_OAUTH.glm.refresh).toBeUndefined();
});
it("is wired into the generic OAuth provider registry", () => {
expect(getProvider("glm")).toBe(glmOauthProvider);
});
});
describe("glm OAuth flow (ZCode CLI poll protocol)", () => {
let calls;
beforeEach(() => {
calls = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url, init = {}) => {
const entry = { url: String(url), method: init.method || "GET", init };
calls.push(entry);
const u = new URL(url);
if (u.href === "https://zcode.z.ai/api/v1/oauth/cli/init") {
return jsonResponse({
code: 0,
data: {
authorize_url: "https://chat.z.ai/api/oauth/authorize?x=1",
flow_id: "flow-123",
poll_interval_sec: 2,
expires_at: Math.floor(Date.now() / 1000) + 300,
},
});
}
if (u.pathname.startsWith("/api/v1/oauth/cli/poll/")) {
if (globalThis.__glmPollState === "pending") {
return jsonResponse({ code: 0, data: { status: "pending" } });
}
return jsonResponse({
code: 0,
data: {
status: "ready",
token: "zcode-jwt",
providerId: "zai",
user: { user_id: "u-1", name: "Feavy", email: "feavy@example.com" },
zai: { access_token: "zai-oauth-token", refresh_token: "zai-refresh-token" },
},
});
}
if (u.href === "https://api.z.ai/api/auth/z/login") {
return jsonResponse({ code: 200, data: { access_token: "zai-business-jwt" } });
}
if (u.href === "https://api.z.ai/api/biz/customer/getCustomerInfo") {
return jsonResponse({
code: 200,
data: {
organizations: [
{
organizationId: "org-1",
organizationName: "默认机构",
projects: [{ projectId: "p-1", projectName: "默认项目", projectType: "1" }],
},
],
},
});
}
if (u.pathname.endsWith("/api_keys") && entry.method === "GET") {
return jsonResponse({ code: 200, data: [] });
}
if (u.pathname.endsWith("/api_keys")) {
return jsonResponse({ code: 200, data: { apiKey: "key123", name: "zcode-api-key" } });
}
if (u.pathname.endsWith("/copy/key123")) {
return jsonResponse({ code: 200, data: { secretKey: "secret456" } });
}
return jsonResponse({ code: 500, msg: `unexpected ${url}` }, 500);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
delete globalThis.__glmPollState;
});
it("init returns the server-generated authorize URL + flow id", async () => {
const device = await glmOauthProvider.requestDeviceCode(glmOauthProvider.config);
expect(device.device_code).toBe("flow-123");
expect(device.verification_uri).toBe("https://chat.z.ai/api/oauth/authorize?x=1");
expect(device._zcodePollToken).toEqual(expect.any(String));
expect(calls[0].init.headers.Authorization).toMatch(/^Bearer /);
expect(JSON.parse(calls[0].init.body)).toEqual({ provider: "zai" });
});
it("maps pending poll state to authorization_pending", async () => {
globalThis.__glmPollState = "pending";
const result = await glmOauthProvider.pollToken(glmOauthProvider.config, "flow-123", null, {
_zcodePollToken: "t",
});
expect(result).toEqual({ ok: true, data: { error: "authorization_pending" } });
});
it("derives the coding-plan API key from the ready state", async () => {
const result = await glmOauthProvider.pollToken(glmOauthProvider.config, "flow-123", null, {
_zcodePollToken: "t",
});
expect(result.ok).toBe(true);
expect(result.data.access_token).toBe(PLAN_KEY);
expect(result.data._zcodeJwtToken).toBe("zcode-jwt");
// derivation chain: business login → customer info → api_keys create → copy
const urls = calls.map((c) => c.url);
expect(urls).toContain("https://api.z.ai/api/auth/z/login");
expect(urls).toContain("https://api.z.ai/api/biz/customer/getCustomerInfo");
expect(urls).toContain("https://api.z.ai/api/biz/v1/organization/org-1/projects/p-1/api_keys");
expect(urls).toContain(
"https://api.z.ai/api/biz/v1/organization/org-1/projects/p-1/api_keys/copy/key123",
);
});
it("mapTokens stores the plan key + account identity (no refresh token)", () => {
const tokens = glmOauthProvider.mapTokens({
access_token: PLAN_KEY,
_zcodeJwtToken: "zcode-jwt",
_zaiBusinessToken: "zai-business-jwt",
_zaiRefreshToken: "zai-refresh-token",
_zcodeUser: { user_id: "u-1", name: "Feavy", email: "feavy@example.com" },
});
expect(tokens.accessToken).toBe(PLAN_KEY);
expect(tokens.refreshToken).toBeNull();
expect(tokens.email).toBe("feavy@example.com");
expect(tokens.displayName).toBe("Feavy");
expect(tokens.providerSpecificData).toMatchObject({
authMethod: "cli_poll",
username: "Feavy",
userId: "u-1",
zcodeJwtToken: "zcode-jwt",
zaiBusinessToken: "zai-business-jwt",
zaiRefreshToken: "zai-refresh-token",
});
});
it("fails cleanly without the poll token (restart required)", async () => {
const result = await glmOauthProvider.pollToken(glmOauthProvider.config, "flow-123", null, {});
expect(result.data.error).toBe("access_denied");
});
});
describe("glm executor + usage (dual-auth credentials)", () => {
it("sends the plan key as x-api-key (no gateway hook)", () => {
const executor = new DefaultExecutor("glm");
const creds = { accessToken: PLAN_KEY, refreshToken: null };
const headers = executor.buildHeaders(creds, true, ANTHROPIC_URL, "glm-5.3", {});
expect(headers["x-api-key"]).toBe(PLAN_KEY);
expect(headers["Authorization"]).toBeUndefined();
});
it("never schedules a token refresh (no refresh grant upstream)", async () => {
const executor = new DefaultExecutor("glm");
const result = await executor.refreshCredentials(
{ accessToken: PLAN_KEY, refreshToken: null },
console,
);
expect(result).toBeNull();
});
it("fetches quota with the OAuth-minted key stored on accessToken", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse({
data: {
level: "PRO",
limits: [
{ type: "TOKENS_LIMIT", percentage: 35.5, number: 5, unit: 3, nextResetTime: Date.now() + 3600_000 },
],
},
}),
);
const usage = await getUsageForProvider(
{ provider: "glm", accessToken: PLAN_KEY, apiKey: null, providerSpecificData: {} },
null,
);
expect(usage.plan).toBe("Pro");
expect(usage.quotas["Session (5h)"].used).toBe(35.5);
expect(proxyAwareFetch).toHaveBeenCalledWith(
"https://api.z.ai/api/monitor/usage/quota/limit",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${PLAN_KEY}` }) }),
null,
);
});
it("still fetches quota for pasted apikey connections", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse({ data: { level: "PRO", limits: [] } }),
);
const usage = await getUsageForProvider(
{ provider: "glm", accessToken: null, apiKey: PLAN_KEY, providerSpecificData: {} },
null,
);
expect(usage.plan).toBe("Pro");
});
});