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:
Mihai
2026-07-23 14:42:15 +03:00
parent e06ac0b787
commit f1e70be886
7 changed files with 618 additions and 4 deletions
+285
View File
@@ -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();
}
+2 -1
View File
@@ -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,
+14
View File
@@ -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;
+9 -3
View File
@@ -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);
+246
View File
@@ -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");
});
});
+61
View File
@@ -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");
});
});