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:
1 parent
6b9dc54dd2
commit
068ce87d20
11 files changed
+656
-58
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -12,9 +12,55 @@ 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 function parseGlmQuotaResponse(json) {
|
||||
const data = json?.data && typeof json.data === "object" ? json.data : {};
|
||||
const limits = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
// 1. Accept both TOKENS_LIMIT and CREDIT_LIMIT from GLM API
|
||||
if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue;
|
||||
const usedPercent = Number(limit.percentage) || 0;
|
||||
const resetMs = Number(limit.nextResetTime) || 0;
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
// 2. Map key dynamically based on type and period (unit) to avoid overwriting
|
||||
let key = "session";
|
||||
if (limit.unit === 3) {
|
||||
key = `Session (${limit.number}h)`;
|
||||
} else if (limit.unit === 6) {
|
||||
key = "Weekly (7d)";
|
||||
} else if (limit.type === "TOKENS_LIMIT") {
|
||||
key = "Tokens";
|
||||
} else {
|
||||
key = `Limit (${limit.number})`;
|
||||
}
|
||||
|
||||
quotas[key] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
const levelRaw = typeof data.level === "string" ? data.level : "";
|
||||
const plan = levelRaw
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "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." };
|
||||
@@ -43,44 +89,7 @@ export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data = json?.data && typeof json.data === "object" ? json.data : {};
|
||||
const limits = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
// 1. Accept both TOKENS_LIMIT and CREDIT_LIMIT from GLM API
|
||||
if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue;
|
||||
const usedPercent = Number(limit.percentage) || 0;
|
||||
const resetMs = Number(limit.nextResetTime) || 0;
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
// 2. Map key dynamically based on type and period (unit) to avoid overwriting
|
||||
let key = "session";
|
||||
if (limit.unit === 3) {
|
||||
key = `Session (${limit.number}h)`;
|
||||
} else if (limit.unit === 6) {
|
||||
key = "Weekly (7d)";
|
||||
} else if (limit.type === "TOKENS_LIMIT") {
|
||||
key = "Tokens";
|
||||
} else {
|
||||
key = `Limit (${limit.number})`;
|
||||
}
|
||||
|
||||
quotas[key] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
const levelRaw = typeof data.level === "string" ? data.level : "";
|
||||
const plan = levelRaw
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "Unknown";
|
||||
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,18 +927,20 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<p className="text-2xl font-mono font-bold text-primary">{deviceData.user_code}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
{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">
|
||||
<p className="text-2xl font-mono font-bold text-primary">{deviceData.user_code}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{polling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user