feat(providers): browser OAuth sign-in for Z.AI (GLM Coding Plan)
Adds a login-only `zai-coding-plan` provider that runs the ZCode "Individual Plan" browser sign-in (authorize on chat.z.ai -> token exchange -> business login -> mint a durable `id.secret` key) and stores the minted key under the existing `zai` provider via `storeCredentialsAs`, so `zai`'s models and `zaiUsageProvider` apply with no model or catalog changes. Mirrors the existing `openai-codex-device => openai-codex` pattern. The flow is built on `OAuthCallbackFlow` (loopback callback on port 54548, plus a paste-redirect fallback via `pasteCodeFlow`) and returns `OAuthCredentials` with the minted key in `access`; `getOAuthApiKey` returns it verbatim as the request bearer for `zai`. `zaiUsageProvider` is widened to accept both `api_key` (paste) and `oauth` (sign-in) credentials so `/usage` renders Z.AI quota for both login paths. Strictly additive: the existing paste-key `zai` and `zhipu-coding-plan` logins are unchanged. Tests: full authorize -> token -> biz-login -> key-mint walk (find + create), and `zaiUsageProvider` covering both credential types.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Z.ai / GLM OAuth flow (GLM Coding Plan · Sign in)
|
||||
*
|
||||
* Mirrors ZCode's desktop "Individual Plan" browser sign-in: an
|
||||
* authorization-code flow (no PKCE) against chat.z.ai, a JSON token exchange
|
||||
* that yields a short-lived OAuth access token, and a business-API sequence
|
||||
* that provisions a durable `id.secret` API key. The minted key is placed in
|
||||
* {@link OAuthCredentials.access}; `getOAuthApiKey` returns it verbatim as the
|
||||
* request bearer for the `zai` provider, so no dialect change is needed.
|
||||
*/
|
||||
|
||||
import * as AIError from "../../error";
|
||||
import type { FetchImpl } from "../../types";
|
||||
import { OAuthCallbackFlow } from "./callback-server";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
const env = (key: string): string | undefined => {
|
||||
const value = process.env[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
};
|
||||
|
||||
const CLIENT_ID = env("ZAI_OAUTH_CLIENT_ID") ?? "client_P8X5CMWmlaRO9gyO-KSqtg";
|
||||
const AUTHORIZE_URL = env("ZAI_OAUTH_AUTHORIZE_URL") ?? "https://chat.z.ai/api/oauth/authorize";
|
||||
const TOKEN_URL = env("ZAI_OAUTH_TOKEN_URL") ?? "https://zcode.z.ai/api/v1/oauth/token";
|
||||
const BIZ_BASE = env("ZAI_BIZ_BASE") ?? "https://api.z.ai";
|
||||
/** Business-login endpoint: exchanges the OAuth access token for a biz token. */
|
||||
const BUSINESS_LOGIN_URL = env("ZAI_BUSINESS_LOGIN_URL") ?? "https://api.z.ai/api/auth/z/login";
|
||||
/** OMP's own key name so sign-in never mutates ZCode's `zcode-api-key`. */
|
||||
const KEY_NAME = "oh-my-pi";
|
||||
const CALLBACK_PORT = 54548;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
/** Durable minted key never expires; matches the perplexity NEVER_EXPIRES sentinel. */
|
||||
const NEVER_EXPIRES = 8.64e15;
|
||||
|
||||
function formatErrorDetails(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Z.ai's `{ code, msg, data, success }` envelope. The OAuth token endpoint
|
||||
* signals success with `code: 0`; the biz endpoints (`api.z.ai`) use
|
||||
* `code: 200` / `success: true`. Accept both; throw `msg` on failure. Bodies
|
||||
* without a status wrapper pass through unchanged.
|
||||
*/
|
||||
function isSuccessCode(code: unknown): boolean {
|
||||
if (code == null) return true;
|
||||
if (typeof code === "number") return code === 0 || code === 200;
|
||||
if (typeof code === "string") return code === "0" || code === "200";
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapEnvelope(body: unknown, operation: string): unknown {
|
||||
if (body && typeof body === "object" && ("code" in body || "success" in body)) {
|
||||
const envelope = body as { code?: unknown; msg?: string; data?: unknown; success?: unknown };
|
||||
if (envelope.success === false || !isSuccessCode(envelope.code)) {
|
||||
throw new AIError.OAuthError(`Z.ai ${operation} failed: ${envelope.msg ?? `code ${String(envelope.code)}`}`, {
|
||||
kind: "token-exchange",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
return "data" in envelope ? envelope.data : envelope;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function getJson(url: string, headers: Record<string, string>, fetchImpl: FetchImpl): Promise<unknown> {
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const responseBody = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new AIError.ProviderHttpError(
|
||||
`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return responseBody.length > 0 ? JSON.parse(responseBody) : undefined;
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
url: string,
|
||||
body: Record<string, string | number>,
|
||||
headers: Record<string, string>,
|
||||
fetchImpl: FetchImpl,
|
||||
): Promise<unknown> {
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const responseBody = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new AIError.ProviderHttpError(
|
||||
`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return responseBody.length > 0 ? JSON.parse(responseBody) : undefined;
|
||||
}
|
||||
|
||||
/** Coerce an api_keys list response (bare array or common wrapper shapes) to an array. */
|
||||
function asKeyArray(value: unknown): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(value)) return value as Array<Record<string, unknown>>;
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const field of ["list", "keys", "apiKeys", "records"]) {
|
||||
if (Array.isArray(record[field])) return record[field] as Array<Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the short-lived OAuth access token for a durable biz token via
|
||||
* ZCode's business-login endpoint. The biz APIs reject the raw OAuth token;
|
||||
* they require this token.
|
||||
*/
|
||||
async function businessLogin(oauthAccessToken: string, fetchImpl: FetchImpl): Promise<string> {
|
||||
const data = unwrapEnvelope(
|
||||
await postJson(BUSINESS_LOGIN_URL, { token: oauthAccessToken }, {}, fetchImpl),
|
||||
"business login",
|
||||
) as { access_token?: unknown; accessToken?: unknown } | undefined;
|
||||
const bizToken = trimmedString(data?.access_token) ?? trimmedString(data?.accessToken);
|
||||
if (!bizToken) {
|
||||
throw new AIError.OAuthError("Z.ai business login returned no access token", {
|
||||
kind: "token-exchange",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
return bizToken;
|
||||
}
|
||||
|
||||
interface ZaiProject {
|
||||
projectId?: unknown;
|
||||
isDefault?: unknown;
|
||||
}
|
||||
interface ZaiOrganization {
|
||||
organizationId?: unknown;
|
||||
isDefault?: unknown;
|
||||
projects?: ZaiProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision the durable Z.ai API key from a short-lived OAuth access token,
|
||||
* mirroring ZCode: business-login → resolve default org/project from
|
||||
* `getCustomerInfo` → find/create the OMP-named key → obtain its secret →
|
||||
* return `${apiKey}.${secretKey}` (the 49-char durable key).
|
||||
*/
|
||||
async function mintZaiApiKey(oauthAccessToken: string, fetchImpl: FetchImpl): Promise<string> {
|
||||
const bizToken = await businessLogin(oauthAccessToken, fetchImpl);
|
||||
const auth = { Authorization: `Bearer ${bizToken}` };
|
||||
|
||||
const customer = unwrapEnvelope(
|
||||
await getJson(`${BIZ_BASE}/api/biz/customer/getCustomerInfo`, auth, fetchImpl),
|
||||
"customer lookup",
|
||||
) as { organizations?: ZaiOrganization[] } | undefined;
|
||||
const orgs = Array.isArray(customer?.organizations) ? customer.organizations : [];
|
||||
const org = orgs.find(o => o?.isDefault) ?? orgs[0];
|
||||
const projects = Array.isArray(org?.projects) ? org.projects : [];
|
||||
const project = projects.find(p => p?.isDefault) ?? projects[0];
|
||||
const organizationId = trimmedString(org?.organizationId);
|
||||
const projectId = trimmedString(project?.projectId);
|
||||
if (!organizationId || !projectId) {
|
||||
throw new AIError.OAuthError("Z.ai key provisioning failed: no organization/project on account", {
|
||||
kind: "token-exchange",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
|
||||
const keysUrl = `${BIZ_BASE}/api/biz/v1/organization/${organizationId}/projects/${projectId}/api_keys`;
|
||||
const existing = asKeyArray(unwrapEnvelope(await getJson(keysUrl, auth, fetchImpl), "api key list")).find(
|
||||
key => key.name === KEY_NAME,
|
||||
);
|
||||
const keyRecord =
|
||||
existing ??
|
||||
(unwrapEnvelope(await postJson(keysUrl, { name: KEY_NAME }, auth, fetchImpl), "api key create") as
|
||||
| Record<string, unknown>
|
||||
| undefined);
|
||||
|
||||
const apiKey = trimmedString(keyRecord?.apiKey);
|
||||
if (!apiKey) {
|
||||
throw new AIError.OAuthError("Z.ai key provisioning returned no apiKey", {
|
||||
kind: "token-exchange",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
|
||||
// Always fetch the secret via the copy endpoint: list entries mask it
|
||||
// (`*****abcd`) and the create response's inline secret is not reliable
|
||||
// across account states, whereas copy always returns the full secret.
|
||||
const copied = unwrapEnvelope(
|
||||
await getJson(`${keysUrl}/copy/${encodeURIComponent(apiKey)}`, auth, fetchImpl),
|
||||
"api key copy",
|
||||
) as { secretKey?: unknown } | undefined;
|
||||
const secretKey = trimmedString(copied?.secretKey);
|
||||
if (!secretKey) {
|
||||
throw new AIError.OAuthError("Z.ai key provisioning returned no secretKey", {
|
||||
kind: "token-exchange",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
|
||||
return `${apiKey}.${secretKey}`;
|
||||
}
|
||||
|
||||
export class ZaiOAuthFlow extends OAuthCallbackFlow {
|
||||
#fetch: FetchImpl;
|
||||
|
||||
constructor(ctrl: OAuthController) {
|
||||
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
||||
this.#fetch = ctrl.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
||||
// No PKCE: matches ZCode's authorize request verbatim.
|
||||
const authParams = new URLSearchParams({
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
state,
|
||||
});
|
||||
return {
|
||||
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
||||
instructions:
|
||||
"Complete Z.ai login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
|
||||
if (this.ctrl.signal?.aborted) {
|
||||
throw new AIError.LoginCancelledError(`OAuth callback cancelled: ${this.ctrl.signal.reason}`);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
// Non-standard token body (no grant_type/code_verifier): matches ZCode.
|
||||
body = await postJson(TOKEN_URL, { provider: "zai", code, redirect_uri: redirectUri, state }, {}, this.#fetch);
|
||||
} catch (error) {
|
||||
throw new AIError.OAuthError(
|
||||
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; details=${formatErrorDetails(error)}`,
|
||||
{ kind: "token-exchange", provider: "zai", cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
const data = unwrapEnvelope(body, "token exchange") as
|
||||
| { zai?: { access_token?: unknown }; user?: { email?: unknown; id?: unknown } }
|
||||
| undefined;
|
||||
const oauthAccessToken = trimmedString(data?.zai?.access_token);
|
||||
if (!oauthAccessToken) {
|
||||
throw new AIError.OAuthError("Z.ai token response missing access token", {
|
||||
kind: "validation",
|
||||
provider: "zai",
|
||||
});
|
||||
}
|
||||
|
||||
const mintedKey = await mintZaiApiKey(oauthAccessToken, this.#fetch);
|
||||
|
||||
return {
|
||||
access: mintedKey,
|
||||
refresh: "",
|
||||
expires: NEVER_EXPIRES,
|
||||
email: typeof data?.user?.email === "string" ? data.user.email : undefined,
|
||||
accountId:
|
||||
typeof data?.user?.id === "string" || typeof data?.user?.id === "number" ? String(data.user.id) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Z.ai OAuth (GLM Coding Plan).
|
||||
*/
|
||||
export async function loginZaiOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
|
||||
const flow = new ZaiOAuthFlow(ctrl);
|
||||
return flow.login();
|
||||
}
|
||||
@@ -64,7 +64,7 @@ import { xiaomiProvider } from "./xiaomi";
|
||||
import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams";
|
||||
import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn";
|
||||
import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp";
|
||||
import { zaiProvider } from "./zai";
|
||||
import { zaiCodingPlanProvider, zaiProvider } from "./zai";
|
||||
import { zenmuxProvider } from "./zenmux";
|
||||
import { zhipuCodingPlanProvider } from "./zhipu-coding-plan";
|
||||
|
||||
@@ -80,6 +80,7 @@ const ALL = [
|
||||
openaiCodexProvider,
|
||||
anthropicProvider,
|
||||
zaiProvider,
|
||||
zaiCodingPlanProvider,
|
||||
kimiCodeProvider,
|
||||
openrouterProvider,
|
||||
githubCopilotProvider,
|
||||
|
||||
@@ -25,3 +25,17 @@ export const zaiProvider = {
|
||||
name: "Z.AI (GLM Coding Plan)",
|
||||
login: (cb: OAuthLoginCallbacks) => loginZai(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
|
||||
export const zaiCodingPlanProvider = {
|
||||
id: "zai-coding-plan",
|
||||
name: "Z.AI (GLM Coding Plan · Sign in)",
|
||||
// Minted key lives in creds.access; getOAuthApiKey returns it for the `zai`
|
||||
// catalog provider verbatim, so store credentials under `zai`.
|
||||
storeCredentialsAs: "zai",
|
||||
// Loopback callback server on this port, plus the manual paste-code
|
||||
// fallback (PASTE_CODE_LOGIN_PROVIDERS) for when the browser cannot reach
|
||||
// this machine. Mirrors the Anthropic sign-in wiring.
|
||||
callbackPort: 54548,
|
||||
pasteCodeFlow: true,
|
||||
login: (cb: OAuthLoginCallbacks) => import("./oauth/zai").then(m => m.loginZaiOAuth(cb)),
|
||||
} as const satisfies ProviderDefinition;
|
||||
|
||||
@@ -220,12 +220,16 @@ function rankZaiRequestLimits(report: UsageReport): UsageLimit[] {
|
||||
async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
||||
if (params.provider !== "zai") return null;
|
||||
const credential = params.credential;
|
||||
if (credential.type !== "api_key" || !credential.apiKey) return null;
|
||||
// Sign-in (oauth) stores the minted id.secret key in accessToken; the paste
|
||||
// path stores it in apiKey. Both are the same raw key used verbatim as the
|
||||
// Authorization header (no Bearer prefix).
|
||||
const token = credential.type === "oauth" ? credential.accessToken : credential.apiKey;
|
||||
if (!token) return null;
|
||||
|
||||
const baseUrl = normalizeZaiBaseUrl(params.baseUrl);
|
||||
const url = `${baseUrl}${QUOTA_PATH}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: credential.apiKey,
|
||||
Authorization: token,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenCode-Status-Plugin/1.0",
|
||||
};
|
||||
@@ -345,7 +349,9 @@ async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext):
|
||||
export const zaiUsageProvider: UsageProvider = {
|
||||
id: "zai",
|
||||
fetchUsage: fetchZaiUsage,
|
||||
supports: params => params.provider === "zai" && params.credential.type === "api_key",
|
||||
supports: params =>
|
||||
params.provider === "zai" &&
|
||||
(params.credential.type === "oauth" ? Boolean(params.credential.accessToken) : Boolean(params.credential.apiKey)),
|
||||
};
|
||||
|
||||
export const zaiRankingStrategy: CredentialRankingStrategy = {
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("provider registry auth surface", () => {
|
||||
"google-antigravity",
|
||||
"google-gemini-cli",
|
||||
"openai-codex",
|
||||
"zai-coding-plan",
|
||||
].sort(),
|
||||
);
|
||||
expect(PASTE_CODE_LOGIN_PROVIDERS.has("zenmux")).toBe(false);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import { ZaiOAuthFlow } from "@oh-my-pi/pi-ai/registry/oauth/zai";
|
||||
|
||||
const CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
|
||||
const AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize";
|
||||
const TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
|
||||
const BUSINESS_LOGIN_URL = "https://api.z.ai/api/auth/z/login";
|
||||
const BIZ_BASE = "https://api.z.ai";
|
||||
const KEYS_URL = `${BIZ_BASE}/api/biz/v1/organization/org-1/projects/proj-1/api_keys`;
|
||||
const REDIRECT_URI = "http://localhost:54548/callback";
|
||||
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
authorization: string | null;
|
||||
}
|
||||
|
||||
/** OAuth token endpoint signals success with `code: 0`. */
|
||||
function tokenEnvelope(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ code: 0, msg: "ok", data }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Biz endpoints (api.z.ai) signal success with `code: 200` / `success: true`. */
|
||||
function bizEnvelope(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ code: 200, msg: "Operation successful", success: true, data }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mocked fetch for the full authorize → token → business-login →
|
||||
* getCustomerInfo → api_keys → copy walk, matching the live Z.ai API shapes.
|
||||
* `existingKeys` seeds the api_keys list to exercise find vs create.
|
||||
*/
|
||||
function makeBizFetch(
|
||||
options: {
|
||||
existingKeys?: Array<Record<string, unknown>>;
|
||||
tokenResponse?: Response;
|
||||
businessResponse?: Response;
|
||||
customerResponse?: Response;
|
||||
} = {},
|
||||
) {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const existingKeys = options.existingKeys ?? [];
|
||||
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const method = init?.method ?? "GET";
|
||||
const rawBody = init?.body;
|
||||
const body = typeof rawBody === "string" && rawBody.length > 0 ? JSON.parse(rawBody) : undefined;
|
||||
requests.push({ url, method, body, authorization: new Headers(init?.headers).get("Authorization") });
|
||||
|
||||
if (url === TOKEN_URL) {
|
||||
return (
|
||||
options.tokenResponse ??
|
||||
tokenEnvelope({
|
||||
token: "zcode-jwt",
|
||||
zai: { access_token: "oauth-access-token" },
|
||||
user: { email: "user@example.com", id: "user-42" },
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === BUSINESS_LOGIN_URL) {
|
||||
return options.businessResponse ?? bizEnvelope({ access_token: "biz-token", expires_in: 3600 });
|
||||
}
|
||||
if (url === `${BIZ_BASE}/api/biz/customer/getCustomerInfo`) {
|
||||
return (
|
||||
options.customerResponse ??
|
||||
bizEnvelope({
|
||||
organizations: [
|
||||
{
|
||||
organizationId: "org-1",
|
||||
isDefault: true,
|
||||
projects: [{ projectId: "proj-1", isDefault: true }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === KEYS_URL && method === "GET") {
|
||||
return bizEnvelope(existingKeys);
|
||||
}
|
||||
if (url === KEYS_URL && method === "POST") {
|
||||
// Create returns an inline secret; the flow must IGNORE it and copy.
|
||||
return bizEnvelope({ name: "oh-my-pi", apiKey: "created-key", secretKey: "inline-ignored" });
|
||||
}
|
||||
if (url.startsWith(`${KEYS_URL}/copy/`)) {
|
||||
const apiKey = decodeURIComponent(url.slice(`${KEYS_URL}/copy/`.length));
|
||||
return bizEnvelope({ apiKey, secretKey: "real-secret" });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${url}`);
|
||||
});
|
||||
return { fetchMock, requests };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("zai oauth flow", () => {
|
||||
it("generates a no-PKCE authorization URL with the ZCode client id", async () => {
|
||||
const flow = new ZaiOAuthFlow({});
|
||||
const { url } = await flow.generateAuthUrl("state-abc", REDIRECT_URI);
|
||||
const authUrl = new URL(url);
|
||||
|
||||
expect(authUrl.origin + authUrl.pathname).toBe(AUTHORIZE_URL);
|
||||
expect(authUrl.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(authUrl.searchParams.get("response_type")).toBe("code");
|
||||
expect(authUrl.searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
|
||||
expect(authUrl.searchParams.get("state")).toBe("state-abc");
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeNull();
|
||||
expect(authUrl.searchParams.get("code_challenge_method")).toBeNull();
|
||||
});
|
||||
|
||||
it("exchanges the code, does business-login, then mints an id.secret key (create path)", async () => {
|
||||
const { fetchMock, requests } = makeBizFetch();
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
|
||||
const creds = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI);
|
||||
|
||||
// Durable minted key: apiKey.secret, with the secret from COPY (not the inline create value).
|
||||
expect(creds.access).toBe("created-key.real-secret");
|
||||
expect(creds.refresh).toBe("");
|
||||
expect(creds.expires).toBe(8.64e15);
|
||||
expect(creds.email).toBe("user@example.com");
|
||||
expect(creds.accountId).toBe("user-42");
|
||||
|
||||
// Ordered walk: token → business-login → customer → list → create → copy.
|
||||
expect(requests.map(r => `${r.method} ${r.url}`)).toEqual([
|
||||
`POST ${TOKEN_URL}`,
|
||||
`POST ${BUSINESS_LOGIN_URL}`,
|
||||
`GET ${BIZ_BASE}/api/biz/customer/getCustomerInfo`,
|
||||
`GET ${KEYS_URL}`,
|
||||
`POST ${KEYS_URL}`,
|
||||
`GET ${KEYS_URL}/copy/created-key`,
|
||||
]);
|
||||
|
||||
// Token exchange body is ZCode's non-RFC JSON shape.
|
||||
expect(requests[0]?.body).toEqual({
|
||||
provider: "zai",
|
||||
code: "auth-code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
state: "state-abc",
|
||||
});
|
||||
// Business login exchanges the OAuth access token for a biz token.
|
||||
expect(requests[1]?.body).toEqual({ token: "oauth-access-token" });
|
||||
// Every biz call is authorized with the biz token (not the OAuth token).
|
||||
for (const bizReq of requests.slice(2)) {
|
||||
expect(bizReq.authorization).toBe("Bearer biz-token");
|
||||
}
|
||||
// Created OMP's own key name, never ZCode's.
|
||||
expect(requests[4]?.body).toEqual({ name: "oh-my-pi" });
|
||||
});
|
||||
|
||||
it("reuses an existing key and takes the full secret from copy, not the masked list value", async () => {
|
||||
const { fetchMock, requests } = makeBizFetch({
|
||||
existingKeys: [
|
||||
{ name: "zcode-api-key", apiKey: "zcode-key", secretKey: "*****aaaa" },
|
||||
{ name: "oh-my-pi", apiKey: "existing-key", secretKey: "*****pz5Y" },
|
||||
],
|
||||
});
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
|
||||
const creds = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI);
|
||||
|
||||
// Must use the copy secret ("real-secret"), NOT the masked list secret ("*****pz5Y").
|
||||
expect(creds.access).toBe("existing-key.real-secret");
|
||||
expect(requests.some(r => r.method === "POST" && r.url === KEYS_URL)).toBe(false);
|
||||
expect(requests.at(-1)).toMatchObject({ method: "GET", url: `${KEYS_URL}/copy/existing-key` });
|
||||
});
|
||||
|
||||
it("resolves the default organization and project from the nested customer response", async () => {
|
||||
const { fetchMock, requests } = makeBizFetch({
|
||||
customerResponse: bizEnvelope({
|
||||
organizations: [
|
||||
{ organizationId: "org-other", isDefault: false, projects: [{ projectId: "proj-x", isDefault: true }] },
|
||||
{
|
||||
organizationId: "org-default",
|
||||
isDefault: true,
|
||||
projects: [
|
||||
{ projectId: "proj-a", isDefault: false },
|
||||
{ projectId: "proj-default", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
// Re-point KEYS_URL routing for the default org/project this test expects.
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
const creds = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI).catch((e: unknown) => e);
|
||||
|
||||
// The keys URL must target org-default/proj-default (the isDefault entries).
|
||||
const customerIdx = requests.findIndex(r => r.url.endsWith("getCustomerInfo"));
|
||||
const afterCustomer = requests.slice(customerIdx + 1);
|
||||
expect(afterCustomer[0]?.url).toBe(
|
||||
`${BIZ_BASE}/api/biz/v1/organization/org-default/projects/proj-default/api_keys`,
|
||||
);
|
||||
// Flow then errors (that keys URL is unrouted) — proves selection, not a full mint.
|
||||
expect(creds).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("throws OAuthError when the token envelope reports a non-zero code", async () => {
|
||||
const { fetchMock } = makeBizFetch({
|
||||
tokenResponse: new Response(JSON.stringify({ code: 1, msg: "nope" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
});
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
|
||||
const error = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(AIError.OAuthError);
|
||||
expect((error as AIError.OAuthError).message).toContain("nope");
|
||||
});
|
||||
|
||||
it("throws OAuthError when business login reports success:false", async () => {
|
||||
const { fetchMock } = makeBizFetch({
|
||||
businessResponse: new Response(
|
||||
JSON.stringify({ code: 401, success: false, msg: "Authorization Token illegal" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
});
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
|
||||
const error = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(AIError.OAuthError);
|
||||
expect((error as AIError.OAuthError).message).toContain("business login failed");
|
||||
});
|
||||
|
||||
it("throws OAuthError when the token response omits the access token", async () => {
|
||||
const { fetchMock } = makeBizFetch({ tokenResponse: tokenEnvelope({ token: "zcode-jwt", user: {} }) });
|
||||
const flow = new ZaiOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
|
||||
const error = await flow.exchangeToken("auth-code", "state-abc", REDIRECT_URI).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(AIError.OAuthError);
|
||||
expect((error as AIError.OAuthError).message).toContain("missing access token");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,33 @@ function makeCtx(payload: unknown): UsageFetchContext {
|
||||
return { fetch };
|
||||
}
|
||||
|
||||
function makeOAuthCredential(): UsageFetchParams["credential"] {
|
||||
return {
|
||||
type: "oauth",
|
||||
accessToken: "minted-id.minted-secret",
|
||||
accountId: "acc-1",
|
||||
email: "user@example.com",
|
||||
};
|
||||
}
|
||||
|
||||
function makeRecordingCtx(payload: unknown, sink: { authorization?: string }): UsageFetchContext {
|
||||
const fetch: FetchImpl = async (input, init) => {
|
||||
const url = String(input);
|
||||
sink.authorization = new Headers(init?.headers).get("Authorization") ?? undefined;
|
||||
if (url.includes("/api/monitor/usage/model-usage")) {
|
||||
return new Response(JSON.stringify({ success: true, data: {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
return { fetch };
|
||||
}
|
||||
|
||||
describe("zai usage provider", () => {
|
||||
it("preserves Z.AI token quota windows instead of treating them as separate accounts", async () => {
|
||||
const report = await zaiUsageProvider.fetchUsage!(
|
||||
@@ -77,4 +104,38 @@ describe("zai usage provider", () => {
|
||||
7 * 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports both api-key and oauth credentials, rejecting oauth rows with no access token", () => {
|
||||
expect(zaiUsageProvider.supports!({ provider: "zai", credential: makeCredential(), signal: undefined })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
zaiUsageProvider.supports!({ provider: "zai", credential: makeOAuthCredential(), signal: undefined }),
|
||||
).toBe(true);
|
||||
expect(zaiUsageProvider.supports!({ provider: "zai", credential: { type: "oauth" }, signal: undefined })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches quota for an oauth sign-in credential using the minted key as the auth header", async () => {
|
||||
const sink: { authorization?: string } = {};
|
||||
const report = await zaiUsageProvider.fetchUsage!(
|
||||
{ provider: "zai", credential: makeOAuthCredential(), signal: undefined },
|
||||
makeRecordingCtx(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
limits: [{ type: "TOKENS_LIMIT", percentage: 82, nextResetTime: 1782656863894, unit: 3, number: 5 }],
|
||||
},
|
||||
},
|
||||
sink,
|
||||
),
|
||||
);
|
||||
|
||||
expect(report).not.toBeNull();
|
||||
expect(report!.limits[0]?.id).toBe("zai:tokens:5h");
|
||||
expect(report!.metadata?.accountId).toBe("acc-1");
|
||||
// Minted id.secret key sent verbatim (no Bearer prefix), same as the paste path.
|
||||
expect(sink.authorization).toBe("minted-id.minted-secret");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user