diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 42fbed6f..56c51db4 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -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 diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js index cec99deb..eef4de4f 100644 --- a/cli/src/cli/utils/modelSelector.js +++ b/cli/src/cli/utils/modelSelector.js @@ -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", diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index 88f4c563..b473ae71 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -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", diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 3ce46cfa..71b53cc9 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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), diff --git a/open-sse/services/usage/glm.js b/open-sse/services/usage/glm.js index f4064af6..92296490 100644 --- a/open-sse/services/usage/glm.js +++ b/open-sse/services/usage/glm.js @@ -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}` }; diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index b31a0d16..e363833e 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -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) diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 9d9d2a1f..46553b35 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -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", }; diff --git a/src/lib/oauth/providers/glm.js b/src/lib/oauth/providers/glm.js new file mode 100644 index 00000000..bac45637 --- /dev/null +++ b/src/lib/oauth/providers/glm.js @@ -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 {"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}/ Authorization: Bearer +// → { 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": } → { 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; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index 80063950..8a27363d 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -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 }; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 6b73bc8b..95d50382 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -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, -
-

Your Code

-
-

{deviceData.user_code}

-
- + )} {polling && (
diff --git a/tests/unit/glm-oauth.test.js b/tests/unit/glm-oauth.test.js new file mode 100644 index 00000000..5499b63e --- /dev/null +++ b/tests/unit/glm-oauth.test.js @@ -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"); + }); +});