@gajae-code/ai 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.1] - 2026-06-23
6
+
7
+ ### Changed
8
+
9
+ - Reworked the unofficial, opt-in `glm-zcode` provider to mirror how the ZCode desktop app actually reaches GLM: after the ZCode OAuth handshake it now auto-provisions a real Z.AI API key and calls `api.z.ai/api/anthropic` directly, instead of the `zcode.z.ai` coding-plan gateway that required an Aliyun captcha and a ZCode-JWT-bound plan entitlement. Requests also carry ZCode client source headers (`User-Agent: ZCode/<ver>`, `X-ZCode-Agent: glm`, plus platform/locale/timezone), so Z.AI recognizes the caller as the ZCode client (#1013, #1016, #1017).
10
+
5
11
  ## [0.7.0] - 2026-06-22
6
12
 
7
13
  ### Added
@@ -9,9 +9,24 @@ export type AnthropicHeaderOptions = {
9
9
  stream?: boolean;
10
10
  modelHeaders?: Record<string, string>;
11
11
  isCloudflareAiGateway?: boolean;
12
+ /**
13
+ * Attach ZCode client "source" headers (User-Agent: ZCode/<ver>, X-Title,
14
+ * X-ZCode-Agent: glm, X-Platform, etc.) so api.z.ai recognizes the caller as
15
+ * the ZCode client, exactly like ZCode's `buildZCodeSourceHeaders` does for
16
+ * GLM providers. glm-zcode only.
17
+ */
18
+ zcodeSourceHeaders?: boolean;
12
19
  };
13
20
  export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
14
21
  export declare function buildBetaHeader(baseBetas: string[], extraBetas: string[]): string;
22
+ /**
23
+ * Replicates ZCode's `buildZCodeSourceHeaders()` + GLM `X-ZCode-Agent` tag
24
+ * (host bundle `Bl` / `buildConnectivitySourceHeaders` for GLM providers), so
25
+ * api.z.ai sees gjc's glm-zcode requests as the ZCode client. Dynamic values
26
+ * (platform/arch, locale, timezone, OS version) are resolved at runtime exactly
27
+ * as ZCode does; printable-ASCII-only and conditionally omitted when empty.
28
+ */
29
+ export declare function buildZCodeSourceHeaders(): Record<string, string>;
15
30
  export declare function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string>;
16
31
  type AnthropicCacheControl = {
17
32
  type: "ephemeral";
@@ -1,35 +1,32 @@
1
1
  /**
2
2
  * GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
3
3
  *
4
- * Replicates the reverse-engineered ZCode desktop-app login to Z.AI/GLM. This
5
- * is NOT an official Z.AI OAuth client: it reuses ZCode's authorize page,
6
- * broker, and a custom-protocol redirect. It may break at any time and may
7
- * violate ZCode/Z.AI Terms of Service. Endpoints and the client id are
8
- * overridable via `ZCODE_OAUTH_*` environment variables.
4
+ * Replicates how the ZCode desktop app turns a Z.AI login into usable GLM model
5
+ * access. This is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
6
+ * page, broker, and a custom-protocol redirect. It may break at any time and may
7
+ * violate ZCode/Z.AI Terms of Service. Endpoints/client id are overridable via
8
+ * `ZCODE_OAUTH_*` environment variables.
9
9
  *
10
- * Login flow:
11
- * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
12
- * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
13
- * 2. Broker: POST {broker} { provider: "zai", code, redirect_uri, state }
14
- * → { code: 0, data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> }, expires_in } }
10
+ * Verified end-to-end against the ZCode host bundle (`resolveZaiApiKey` /
11
+ * `resolveBizApiKey`) and live traffic:
12
+ * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
13
+ * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
14
+ * 2. Broker: POST {broker} { provider:"zai", code, redirect_uri, state }
15
+ * → { data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> } } }
16
+ * 3. Business: POST {z/login} { token: <upstream Z.AI token> } → { data: { access_token: <business token> } }
17
+ * 4. Provision: with the business token, GET getCustomerInfo → default org/project,
18
+ * GET/POST .../api_keys (find/create a key named "zcode-api-key"),
19
+ * GET .../api_keys/copy/{id} → secretKey ⇒ a real Z.AI API key "{id}.{secret}".
15
20
  *
16
- * Credential mapping (verified against the ZCode host bundle):
17
- * - `access` = the **ZCode JWT** (`data.token`). This is the GLM coding-plan
18
- * model credential: ZCode stores it under the `zcodejwttoken`
19
- * key and sends it as `Authorization: Bearer` to the coding-plan
20
- * gateway `${ZCODE_PLAN_ANTHROPIC_BASE_URL}` (default
21
- * https://zcode.z.ai/api/v1/zcode-plan/anthropic), which
22
- * validates the ZCode session and injects the upstream GLM key
23
- * server-side. Model traffic does NOT go to api.z.ai directly.
24
- * - `refresh` = the upstream Z.AI OAuth access token (`data.zai.access_token`),
25
- * kept for identity/userinfo only. ZCode's separate z/login
26
- * "business token" is used by ZCode for billing/userinfo, NOT
27
- * model calls, so it is intentionally never minted here.
21
+ * Credential mapping:
22
+ * - `access` = the provisioned **Z.AI API key** ("{id}.{secret}"). Model requests go to
23
+ * `https://api.z.ai/api/anthropic/v1/messages` with `Authorization: Bearer <key>`
24
+ * (exactly like a dashboard key) — NO zcode.z.ai gateway, NO captcha.
25
+ * - `refresh` = the upstream Z.AI OAuth access token (used to re-provision the key).
26
+ * The API key is long-lived, so `expires` is set far in the future.
28
27
  *
29
- * The ZCode JWT reaches the Anthropic-messages request as a plain bearer
30
- * automatically (the gateway base is not api.anthropic.com). This provider must
31
- * NEVER force `isOAuth=true`, which would route GLM into the Claude-Code OAuth
32
- * header branch (claude-cli UA, `claude_` tool prefixes, Claude system prompt).
28
+ * This provider must NEVER force `isOAuth=true`: the key is a plain Z.AI API key, and
29
+ * api.z.ai is not api.anthropic.com, so the Anthropic path already emits a plain bearer.
33
30
  */
34
31
  import { OAuthCallbackFlow } from "./callback-server";
35
32
  import type { OAuthController, OAuthCredentials } from "./types";
@@ -39,19 +36,14 @@ export declare const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oaut
39
36
  export declare const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
40
37
  export declare const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback";
41
38
  export declare const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
39
+ export declare const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login";
42
40
  export declare const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo";
43
- /**
44
- * Default coding-plan ("start plan") Anthropic gateway base. ZCode derives this
45
- * as `${zcodeBackend}/api/v1/zcode-plan/anthropic`. Model requests go here
46
- * (NOT api.z.ai), authenticated with the ZCode JWT. Override via
47
- * `ZCODE_PLAN_ANTHROPIC_BASE_URL`. Exported for the model descriptor / catalog.
48
- */
49
- export declare const GLM_ZCODE_PLAN_ANTHROPIC_BASE_URL = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
41
+ /** Z.AI business API base (customer/org/project/api-key management). */
42
+ export declare const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai";
43
+ /** Model API base — the provisioned key is used here, exactly like a dashboard key. */
44
+ export declare const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
50
45
  type FetchImpl = typeof globalThis.fetch;
51
- /**
52
- * The provider is configured whenever a client id is available. The real ZCode
53
- * client id ships as the default, so this is true unless explicitly cleared.
54
- */
46
+ /** Configured whenever a client id is available; the real ZCode client id ships as default. */
55
47
  export declare function isGlmZcodeOAuthConfigured(): boolean;
56
48
  export interface GlmZcodeOAuthFlowOptions {
57
49
  fetch?: FetchImpl;
@@ -71,10 +63,9 @@ export interface GlmZcodeRefreshOptions {
71
63
  fetch?: FetchImpl;
72
64
  }
73
65
  /**
74
- * The ZCode session JWT is the model credential. ZCode mints it from a one-time
75
- * authorization code via the broker and exposes no documented refresh grant, so
76
- * there is no autonomous refresh: an expired credential requires re-login
77
- * (`/login glm-zcode`). Never return an expired credential as valid.
66
+ * Re-provision the Z.AI API key from the stored upstream token. The key itself is
67
+ * long-lived, so this is rarely needed; if the upstream token has expired it fails
68
+ * loudly and the user must re-login.
78
69
  */
79
- export declare function refreshGlmZcodeToken(_credentials: OAuthCredentials, _options?: AbortSignal | GlmZcodeRefreshOptions): Promise<OAuthCredentials>;
70
+ export declare function refreshGlmZcodeToken(credentials: OAuthCredentials, options?: AbortSignal | GlmZcodeRefreshOptions): Promise<OAuthCredentials>;
80
71
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@gajae-code/utils": "0.7.0",
46
+ "@gajae-code/utils": "0.7.1",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
package/src/models.json CHANGED
@@ -75704,14 +75704,7 @@
75704
75704
  "name": "GLM-5.2 (ZCode)",
75705
75705
  "api": "anthropic-messages",
75706
75706
  "provider": "glm-zcode",
75707
- "baseUrl": "https://zcode.z.ai/api/v1/zcode-plan/anthropic",
75708
- "headers": {
75709
- "User-Agent": "ZCode/1.0.0",
75710
- "HTTP-Referer": "https://zcode.z.ai",
75711
- "X-Title": "Z Code@electron",
75712
- "X-ZCode-App-Version": "1.0.0",
75713
- "X-ZCode-Agent": "glm"
75714
- },
75707
+ "baseUrl": "https://api.z.ai/api/anthropic",
75715
75708
  "reasoning": true,
75716
75709
  "input": [
75717
75710
  "text"
@@ -2282,7 +2282,7 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDe
2282
2282
  anthropicMessagesDescriptor(
2283
2283
  "glm-zcode-coding-plan",
2284
2284
  "glm-zcode",
2285
- process.env.ZCODE_PLAN_ANTHROPIC_BASE_URL ?? "https://zcode.z.ai/api/v1/zcode-plan/anthropic",
2285
+ process.env.ZCODE_PLAN_ANTHROPIC_BASE_URL ?? "https://api.z.ai/api/anthropic",
2286
2286
  ),
2287
2287
  // --- Xiaomi ---
2288
2288
  openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", {
@@ -1,5 +1,6 @@
1
1
  import * as nodeCrypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import { scheduler } from "node:timers/promises";
4
5
  import * as tls from "node:tls";
5
6
  import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
@@ -93,6 +94,13 @@ export type AnthropicHeaderOptions = {
93
94
  stream?: boolean;
94
95
  modelHeaders?: Record<string, string>;
95
96
  isCloudflareAiGateway?: boolean;
97
+ /**
98
+ * Attach ZCode client "source" headers (User-Agent: ZCode/<ver>, X-Title,
99
+ * X-ZCode-Agent: glm, X-Platform, etc.) so api.z.ai recognizes the caller as
100
+ * the ZCode client, exactly like ZCode's `buildZCodeSourceHeaders` does for
101
+ * GLM providers. glm-zcode only.
102
+ */
103
+ zcodeSourceHeaders?: boolean;
96
104
  };
97
105
 
98
106
  export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
@@ -161,6 +169,67 @@ const sharedHeaders = {
161
169
  "X-App": "cli",
162
170
  };
163
171
 
172
+ // ZCode bakes its app version and runtime env at build time. Mirror the values
173
+ // from the analyzed ZCode 3.1.2 desktop bundle (`resolveRuntimeZCodeEnv` returns
174
+ // "production" for non-test builds). Both are overridable for forward-compat.
175
+ const ZCODE_APP_VERSION = process.env.ZCODE_APP_VERSION?.trim() || "3.1.2";
176
+ const ZCODE_RELEASE_CHANNEL = process.env.ZCODE_RELEASE_CHANNEL?.trim() || "production";
177
+
178
+ // Mirrors ZCode's `normalizePrintableHeaderValue`: only printable ASCII passes.
179
+ function normalizePrintableHeaderValue(value: string | undefined): string | undefined {
180
+ const trimmed = value?.trim();
181
+ if (trimmed && /^[\x20-\x7e]+$/.test(trimmed)) return trimmed;
182
+ return undefined;
183
+ }
184
+
185
+ // Mirrors ZCode's `normalizeOsCategory`.
186
+ function normalizeOsCategory(platform: NodeJS.Platform): string {
187
+ switch (platform) {
188
+ case "darwin":
189
+ return "macos";
190
+ case "win32":
191
+ return "windows";
192
+ default:
193
+ return "linux";
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Replicates ZCode's `buildZCodeSourceHeaders()` + GLM `X-ZCode-Agent` tag
199
+ * (host bundle `Bl` / `buildConnectivitySourceHeaders` for GLM providers), so
200
+ * api.z.ai sees gjc's glm-zcode requests as the ZCode client. Dynamic values
201
+ * (platform/arch, locale, timezone, OS version) are resolved at runtime exactly
202
+ * as ZCode does; printable-ASCII-only and conditionally omitted when empty.
203
+ */
204
+ export function buildZCodeSourceHeaders(): Record<string, string> {
205
+ const platform = process.platform;
206
+ const arch = process.arch;
207
+ const appVersion = normalizePrintableHeaderValue(ZCODE_APP_VERSION);
208
+ const releaseChannel = normalizePrintableHeaderValue(ZCODE_RELEASE_CHANNEL);
209
+ let locale: string | undefined;
210
+ let timezone: string | undefined;
211
+ try {
212
+ const resolved = Intl.DateTimeFormat().resolvedOptions();
213
+ locale = normalizePrintableHeaderValue(resolved.locale);
214
+ timezone = normalizePrintableHeaderValue(resolved.timeZone);
215
+ } catch {}
216
+ const osVersion = normalizePrintableHeaderValue(os.version());
217
+ const headers: Record<string, string> = {
218
+ "User-Agent": `ZCode/${appVersion ?? "unknown"}`,
219
+ "HTTP-Referer": "https://zcode.z.ai",
220
+ "X-Title": "Z Code@electron",
221
+ "X-Platform": `${platform}-${arch}`,
222
+ "X-Client-Language": locale ?? "unknown",
223
+ "X-Client-Timezone": timezone ?? "unknown",
224
+ "X-Os-Category": normalizeOsCategory(platform),
225
+ "X-ZCode-Agent": "glm",
226
+ };
227
+ if (appVersion) headers["X-ZCode-App-Version"] = appVersion;
228
+ if (releaseChannel) headers["X-Release-Channel"] = releaseChannel;
229
+ if (osVersion) headers["X-Os-Version"] = osVersion;
230
+ return headers;
231
+ }
232
+
164
233
  export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string> {
165
234
  const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
166
235
  const extraBetas = options.extraBetas ?? [];
@@ -197,6 +266,9 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
197
266
  };
198
267
  } else if (!isAnthropicApiBaseUrl(options.baseUrl)) {
199
268
  const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
269
+ // ZCode merges its source headers LAST for GLM providers (`withZCodeSourceHeaders`
270
+ // → `{ ...base, ...extra, ...source }`), so they win over any incoming User-Agent.
271
+ const zcodeSourceHeaders = options.zcodeSourceHeaders ? buildZCodeSourceHeaders() : undefined;
200
272
  return {
201
273
  ...modelHeaders,
202
274
  Accept: acceptHeader,
@@ -204,6 +276,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
204
276
  ...sharedHeaders,
205
277
  "Anthropic-Beta": betaHeader,
206
278
  ...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
279
+ ...(zcodeSourceHeaders ?? {}),
207
280
  };
208
281
  } else {
209
282
  return {
@@ -678,6 +751,12 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
678
751
  if (model.provider === "github-copilot") {
679
752
  return normalizeAnthropicBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl);
680
753
  }
754
+ // glm-zcode logs in via ZCode's OAuth but auto-provisions a real Z.AI API key and
755
+ // calls api.z.ai directly (no zcode.z.ai gateway, no captcha). Pin the base so dynamic
756
+ // discovery / stale bundled catalogs / model cache can't redirect it elsewhere.
757
+ if (model.provider === "glm-zcode") {
758
+ return normalizeAnthropicBaseUrl(process.env.ZCODE_PLAN_ANTHROPIC_BASE_URL) ?? "https://api.z.ai/api/anthropic";
759
+ }
681
760
  if (model.provider === "anthropic" && isFoundryEnabled()) {
682
761
  const foundryBaseUrl = normalizeAnthropicBaseUrl($env.FOUNDRY_BASE_URL);
683
762
  if (foundryBaseUrl) {
@@ -1697,6 +1776,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
1697
1776
  stream,
1698
1777
  modelHeaders: mergeHeaders(model.headers, foundryCustomHeaders, headers, dynamicHeaders),
1699
1778
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
1779
+ zcodeSourceHeaders: model.provider === "glm-zcode",
1700
1780
  });
1701
1781
 
1702
1782
  if (model.provider === "cloudflare-ai-gateway") {
@@ -1,56 +1,54 @@
1
1
  /**
2
2
  * GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
3
3
  *
4
- * Replicates the reverse-engineered ZCode desktop-app login to Z.AI/GLM. This
5
- * is NOT an official Z.AI OAuth client: it reuses ZCode's authorize page,
6
- * broker, and a custom-protocol redirect. It may break at any time and may
7
- * violate ZCode/Z.AI Terms of Service. Endpoints and the client id are
8
- * overridable via `ZCODE_OAUTH_*` environment variables.
4
+ * Replicates how the ZCode desktop app turns a Z.AI login into usable GLM model
5
+ * access. This is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
6
+ * page, broker, and a custom-protocol redirect. It may break at any time and may
7
+ * violate ZCode/Z.AI Terms of Service. Endpoints/client id are overridable via
8
+ * `ZCODE_OAUTH_*` environment variables.
9
9
  *
10
- * Login flow:
11
- * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
12
- * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
13
- * 2. Broker: POST {broker} { provider: "zai", code, redirect_uri, state }
14
- * → { code: 0, data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> }, expires_in } }
10
+ * Verified end-to-end against the ZCode host bundle (`resolveZaiApiKey` /
11
+ * `resolveBizApiKey`) and live traffic:
12
+ * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
13
+ * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
14
+ * 2. Broker: POST {broker} { provider:"zai", code, redirect_uri, state }
15
+ * → { data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> } } }
16
+ * 3. Business: POST {z/login} { token: <upstream Z.AI token> } → { data: { access_token: <business token> } }
17
+ * 4. Provision: with the business token, GET getCustomerInfo → default org/project,
18
+ * GET/POST .../api_keys (find/create a key named "zcode-api-key"),
19
+ * GET .../api_keys/copy/{id} → secretKey ⇒ a real Z.AI API key "{id}.{secret}".
15
20
  *
16
- * Credential mapping (verified against the ZCode host bundle):
17
- * - `access` = the **ZCode JWT** (`data.token`). This is the GLM coding-plan
18
- * model credential: ZCode stores it under the `zcodejwttoken`
19
- * key and sends it as `Authorization: Bearer` to the coding-plan
20
- * gateway `${ZCODE_PLAN_ANTHROPIC_BASE_URL}` (default
21
- * https://zcode.z.ai/api/v1/zcode-plan/anthropic), which
22
- * validates the ZCode session and injects the upstream GLM key
23
- * server-side. Model traffic does NOT go to api.z.ai directly.
24
- * - `refresh` = the upstream Z.AI OAuth access token (`data.zai.access_token`),
25
- * kept for identity/userinfo only. ZCode's separate z/login
26
- * "business token" is used by ZCode for billing/userinfo, NOT
27
- * model calls, so it is intentionally never minted here.
21
+ * Credential mapping:
22
+ * - `access` = the provisioned **Z.AI API key** ("{id}.{secret}"). Model requests go to
23
+ * `https://api.z.ai/api/anthropic/v1/messages` with `Authorization: Bearer <key>`
24
+ * (exactly like a dashboard key) — NO zcode.z.ai gateway, NO captcha.
25
+ * - `refresh` = the upstream Z.AI OAuth access token (used to re-provision the key).
26
+ * The API key is long-lived, so `expires` is set far in the future.
28
27
  *
29
- * The ZCode JWT reaches the Anthropic-messages request as a plain bearer
30
- * automatically (the gateway base is not api.anthropic.com). This provider must
31
- * NEVER force `isOAuth=true`, which would route GLM into the Claude-Code OAuth
32
- * header branch (claude-cli UA, `claude_` tool prefixes, Claude system prompt).
28
+ * This provider must NEVER force `isOAuth=true`: the key is a plain Z.AI API key, and
29
+ * api.z.ai is not api.anthropic.com, so the Anthropic path already emits a plain bearer.
33
30
  */
34
31
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions, parseCallbackInput } from "./callback-server";
35
32
  import type { OAuthController, OAuthCredentials } from "./types";
36
33
 
37
34
  const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
38
35
  export const GLM_ZCODE_REFRESH_SKEW_MS = 2 * 60 * 1000;
36
+ /** Provisioned API keys are long-lived; pin expiry far out so AuthStorage never force-refreshes. */
37
+ const GLM_ZCODE_API_KEY_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
38
+ /** Name ZCode gives the API key it auto-provisions (host bundle constant `FI`). */
39
+ const GLM_ZCODE_API_KEY_NAME = "zcode-api-key";
39
40
 
40
41
  /** Default endpoints / client id. Override via the matching `ZCODE_OAUTH_*` env vars. */
41
42
  export const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize";
42
43
  export const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
43
44
  export const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback";
44
45
  export const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
46
+ export const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login";
45
47
  export const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo";
46
-
47
- /**
48
- * Default coding-plan ("start plan") Anthropic gateway base. ZCode derives this
49
- * as `${zcodeBackend}/api/v1/zcode-plan/anthropic`. Model requests go here
50
- * (NOT api.z.ai), authenticated with the ZCode JWT. Override via
51
- * `ZCODE_PLAN_ANTHROPIC_BASE_URL`. Exported for the model descriptor / catalog.
52
- */
53
- export const GLM_ZCODE_PLAN_ANTHROPIC_BASE_URL = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
48
+ /** Z.AI business API base (customer/org/project/api-key management). */
49
+ export const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai";
50
+ /** Model API base — the provisioned key is used here, exactly like a dashboard key. */
51
+ export const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
54
52
 
55
53
  type FetchImpl = typeof globalThis.fetch;
56
54
 
@@ -71,19 +69,22 @@ function resolveRedirectUri(): string {
71
69
  function resolveBrokerTokenUrl(): string {
72
70
  return envOr("ZCODE_OAUTH_BROKER_TOKEN_URL", GLM_ZCODE_OAUTH_BROKER_TOKEN_URL);
73
71
  }
72
+ function resolveZaiLoginUrl(): string {
73
+ return envOr("ZCODE_OAUTH_ZAI_LOGIN_URL", GLM_ZCODE_ZAI_LOGIN_URL);
74
+ }
74
75
  function resolveUserinfoUrl(): string {
75
76
  return envOr("ZCODE_OAUTH_USERINFO_URL", GLM_ZCODE_USERINFO_URL);
76
77
  }
78
+ function resolveZaiApiBase(): string {
79
+ return envOr("ZCODE_OAUTH_ZAI_API_BASE", GLM_ZCODE_ZAI_API_BASE).replace(/\/+$/, "");
80
+ }
77
81
 
78
- /**
79
- * The provider is configured whenever a client id is available. The real ZCode
80
- * client id ships as the default, so this is true unless explicitly cleared.
81
- */
82
+ /** Configured whenever a client id is available; the real ZCode client id ships as default. */
82
83
  export function isGlmZcodeOAuthConfigured(): boolean {
83
84
  return resolveClientId().length > 0;
84
85
  }
85
86
 
86
- /** Mask token-like substrings so broker/upstream/JWT tokens never leak into errors or logs. */
87
+ /** Mask token-like substrings so broker/upstream/business tokens never leak into errors. */
87
88
  function redactSecrets(text: string): string {
88
89
  return text
89
90
  .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted-jwt]")
@@ -100,7 +101,7 @@ function validateHttpsEndpoint(rawUrl: string, label: string): string {
100
101
  if (parsed.protocol !== "https:") {
101
102
  throw new Error(`GLM ZCode ${label} endpoint must use https`);
102
103
  }
103
- return parsed.toString();
104
+ return parsed.toString().replace(/\/+$/, "");
104
105
  }
105
106
 
106
107
  function requestSignal(signal: AbortSignal | undefined): AbortSignal {
@@ -118,10 +119,13 @@ async function postJson(
118
119
  body: Record<string, unknown>,
119
120
  label: string,
120
121
  signal: AbortSignal | undefined,
122
+ bearer?: string,
121
123
  ): Promise<unknown> {
124
+ const headers: Record<string, string> = { Accept: "application/json", "Content-Type": "application/json" };
125
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
122
126
  const response = await fetchImpl(url, {
123
127
  method: "POST",
124
- headers: { Accept: "application/json", "Content-Type": "application/json" },
128
+ headers,
125
129
  body: JSON.stringify(body),
126
130
  signal: requestSignal(signal),
127
131
  });
@@ -131,14 +135,28 @@ async function postJson(
131
135
  return response.json();
132
136
  }
133
137
 
138
+ async function getJson(
139
+ fetchImpl: FetchImpl,
140
+ url: string,
141
+ bearer: string,
142
+ label: string,
143
+ signal: AbortSignal | undefined,
144
+ ): Promise<unknown> {
145
+ const response = await fetchImpl(url, {
146
+ headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
147
+ signal: requestSignal(signal),
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`GLM ZCode ${label} request failed: ${response.status} ${redactSecrets(await response.text())}`);
151
+ }
152
+ return response.json();
153
+ }
154
+
134
155
  interface JwtPayload {
135
156
  sub?: unknown;
136
157
  email?: unknown;
137
- account_id?: unknown;
138
- uid?: unknown;
139
158
  [key: string]: unknown;
140
159
  }
141
-
142
160
  function decodeJwtPayload(token: string): JwtPayload | undefined {
143
161
  const parts = token.split(".");
144
162
  const payload = parts[1];
@@ -155,31 +173,118 @@ interface Identity {
155
173
  accountId?: string;
156
174
  }
157
175
 
158
- function identityFromJwts(tokens: readonly string[]): Identity {
159
- for (const token of tokens) {
160
- const payload = decodeJwtPayload(token);
161
- if (!payload) continue;
162
- const accountId =
163
- (typeof payload.sub === "string" && payload.sub) ||
164
- (typeof payload.account_id === "string" && payload.account_id) ||
165
- (typeof payload.uid === "string" && payload.uid) ||
166
- undefined;
167
- const email =
168
- typeof payload.email === "string" && payload.email.length > 0 ? payload.email.toLowerCase() : undefined;
169
- if (accountId || email) {
170
- return { accountId: accountId || undefined, email };
171
- }
176
+ function parseBrokerResponse(payload: unknown): { upstreamZaiAccess: string; zcodeToken: string } {
177
+ const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined;
178
+ const zcodeToken = data && typeof data.token === "string" ? data.token : undefined;
179
+ const zai = data && isRecord(data.zai) ? data.zai : undefined;
180
+ const upstreamZaiAccess = zai && typeof zai.access_token === "string" ? zai.access_token : undefined;
181
+ if (!zcodeToken || !upstreamZaiAccess) {
182
+ throw new Error("GLM ZCode broker response missing data.token or data.zai.access_token");
172
183
  }
173
- return {};
184
+ return { upstreamZaiAccess, zcodeToken };
185
+ }
186
+
187
+ async function resolveBusinessToken(
188
+ fetchImpl: FetchImpl,
189
+ upstreamZaiAccess: string,
190
+ signal: AbortSignal | undefined,
191
+ ): Promise<string> {
192
+ const zaiLoginUrl = validateHttpsEndpoint(resolveZaiLoginUrl(), "z/login");
193
+ const payload = await postJson(fetchImpl, `${zaiLoginUrl}`, { token: upstreamZaiAccess }, "z/login", signal);
194
+ const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined;
195
+ const access = data && typeof data.access_token === "string" ? data.access_token : undefined;
196
+ if (!access) throw new Error("GLM ZCode z/login response missing data.access_token");
197
+ return access;
198
+ }
199
+
200
+ interface OrgProject {
201
+ organizationId: string;
202
+ projectId: string;
203
+ email?: string;
204
+ accountId?: string;
205
+ }
206
+
207
+ function pickDefaultOrgProject(customerInfo: unknown): OrgProject {
208
+ const data = isRecord(customerInfo) && isRecord(customerInfo.data) ? customerInfo.data : customerInfo;
209
+ const root = isRecord(data) ? data : {};
210
+ const orgs = Array.isArray(root.organizations) ? root.organizations : [];
211
+ const org = (orgs.find(o => isRecord(o) && o.isDefault) ?? orgs[0]) as Record<string, unknown> | undefined;
212
+ const organizationId = org && typeof org.organizationId === "string" ? org.organizationId : undefined;
213
+ const projects = org && Array.isArray(org.projects) ? org.projects : [];
214
+ const proj = (projects.find(p => isRecord(p) && p.isDefault) ?? projects[0]) as Record<string, unknown> | undefined;
215
+ const projectId = proj && typeof proj.projectId === "string" ? proj.projectId : undefined;
216
+ if (!organizationId || !projectId) {
217
+ throw new Error("GLM ZCode getCustomerInfo response missing default organization/project");
218
+ }
219
+ const email = typeof root.email === "string" && root.email.length > 0 ? root.email.toLowerCase() : undefined;
220
+ const accountId = typeof root.id === "string" ? root.id : typeof root.id === "number" ? String(root.id) : undefined;
221
+ return { organizationId, projectId, email, accountId };
222
+ }
223
+
224
+ /**
225
+ * Provision (or reuse) a Z.AI API key named "zcode-api-key" using the business token,
226
+ * mirroring ZCode's `resolveBizApiKey`. Returns "{apiKeyId}.{secretKey}".
227
+ */
228
+ async function provisionZaiApiKey(
229
+ fetchImpl: FetchImpl,
230
+ businessToken: string,
231
+ signal: AbortSignal | undefined,
232
+ ): Promise<{ apiKey: string; identity: Identity }> {
233
+ const apiBase = resolveZaiApiBase();
234
+ const customerInfo = await getJson(
235
+ fetchImpl,
236
+ `${apiBase}/api/biz/customer/getCustomerInfo`,
237
+ businessToken,
238
+ "getCustomerInfo",
239
+ signal,
240
+ );
241
+ const { organizationId, projectId, email, accountId } = pickDefaultOrgProject(customerInfo);
242
+ const keysUrl = `${apiBase}/api/biz/v1/organization/${organizationId}/projects/${projectId}/api_keys`;
243
+
244
+ const listPayload = await getJson(fetchImpl, keysUrl, businessToken, "api_keys.list", signal);
245
+ const listData = isRecord(listPayload) && Array.isArray(listPayload.data) ? listPayload.data : [];
246
+ let entry = listData.find(k => isRecord(k) && k.name === GLM_ZCODE_API_KEY_NAME) as
247
+ | Record<string, unknown>
248
+ | undefined;
249
+
250
+ if (!entry) {
251
+ const created = await postJson(
252
+ fetchImpl,
253
+ keysUrl,
254
+ { name: GLM_ZCODE_API_KEY_NAME },
255
+ "api_keys.create",
256
+ signal,
257
+ businessToken,
258
+ );
259
+ entry = (isRecord(created) && isRecord(created.data) ? created.data : created) as Record<string, unknown>;
260
+ }
261
+
262
+ const apiKeyId =
263
+ typeof entry.apiKey === "string" ? entry.apiKey.trim() : typeof entry.id === "string" ? entry.id : "";
264
+ if (!apiKeyId) throw new Error("GLM ZCode api_keys response missing apiKey id");
265
+
266
+ const copyPayload = await getJson(
267
+ fetchImpl,
268
+ `${keysUrl}/copy/${encodeURIComponent(apiKeyId)}`,
269
+ businessToken,
270
+ "api_keys.copy",
271
+ signal,
272
+ );
273
+ const copyData = isRecord(copyPayload) && isRecord(copyPayload.data) ? copyPayload.data : copyPayload;
274
+ const secretKey = isRecord(copyData) && typeof copyData.secretKey === "string" ? copyData.secretKey.trim() : "";
275
+ if (!secretKey) throw new Error("GLM ZCode api_keys copy response missing secretKey");
276
+
277
+ return { apiKey: `${apiKeyId}.${secretKey}`, identity: { email, accountId } };
174
278
  }
175
279
 
176
280
  async function resolveIdentity(
177
281
  fetchImpl: FetchImpl,
178
282
  upstreamZaiAccess: string,
283
+ fallback: Identity,
179
284
  jwtCandidates: readonly string[],
180
285
  signal: AbortSignal | undefined,
181
286
  ): Promise<Identity> {
182
- // Best-effort userinfo; never fail login if identity lookup fails.
287
+ if (fallback.email || fallback.accountId) return fallback;
183
288
  try {
184
289
  const userinfoUrl = validateHttpsEndpoint(resolveUserinfoUrl(), "userinfo");
185
290
  const response = await fetchImpl(userinfoUrl, {
@@ -191,37 +296,47 @@ async function resolveIdentity(
191
296
  const data = isRecord(payload) && isRecord(payload.data) ? payload.data : isRecord(payload) ? payload : {};
192
297
  const email = typeof data.email === "string" && data.email.length > 0 ? data.email.toLowerCase() : undefined;
193
298
  const accountId =
194
- (typeof data.id === "string" && data.id) ||
195
- (typeof data.account_id === "string" && data.account_id) ||
196
- (typeof data.sub === "string" && data.sub) ||
197
- undefined;
198
- if (email || accountId) {
199
- return { email, accountId: accountId || undefined };
200
- }
299
+ (typeof data.id === "string" && data.id) || (typeof data.sub === "string" && data.sub) || undefined;
300
+ if (email || accountId) return { email, accountId: accountId || undefined };
201
301
  }
202
302
  } catch {
203
- // fall through to JWT decode
303
+ // fall through
204
304
  }
205
- return identityFromJwts(jwtCandidates);
305
+ for (const token of jwtCandidates) {
306
+ const p = decodeJwtPayload(token);
307
+ const accountId = p && typeof p.sub === "string" ? p.sub : undefined;
308
+ const email = p && typeof p.email === "string" ? p.email.toLowerCase() : undefined;
309
+ if (accountId || email) return { accountId, email };
310
+ }
311
+ return {};
206
312
  }
207
313
 
208
- interface BrokerResult {
209
- zcodeToken: string;
210
- upstreamZaiAccess: string;
211
- expiresIn: number;
314
+ function credentialsFromApiKey(apiKey: string, upstreamZaiAccess: string, identity: Identity): OAuthCredentials {
315
+ return {
316
+ access: apiKey,
317
+ refresh: upstreamZaiAccess,
318
+ expires: Date.now() + GLM_ZCODE_API_KEY_TTL_MS,
319
+ email: identity.email,
320
+ accountId: identity.accountId,
321
+ };
212
322
  }
213
323
 
214
- function parseBrokerResponse(payload: unknown): BrokerResult {
215
- const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined;
216
- const zcodeToken = data && typeof data.token === "string" ? data.token : undefined;
217
- const zai = data && isRecord(data.zai) ? data.zai : undefined;
218
- const upstreamZaiAccess = zai && typeof zai.access_token === "string" ? zai.access_token : undefined;
219
- if (!zcodeToken || !upstreamZaiAccess) {
220
- throw new Error("GLM ZCode broker response missing data.token or data.zai.access_token");
221
- }
222
- const expiresIn =
223
- data && typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600;
224
- return { zcodeToken, upstreamZaiAccess, expiresIn };
324
+ async function provisionFromUpstream(
325
+ fetchImpl: FetchImpl,
326
+ upstreamZaiAccess: string,
327
+ zcodeTokenForIdentity: string | undefined,
328
+ signal: AbortSignal | undefined,
329
+ ): Promise<OAuthCredentials> {
330
+ const businessToken = await resolveBusinessToken(fetchImpl, upstreamZaiAccess, signal);
331
+ const { apiKey, identity: keyIdentity } = await provisionZaiApiKey(fetchImpl, businessToken, signal);
332
+ const identity = await resolveIdentity(
333
+ fetchImpl,
334
+ upstreamZaiAccess,
335
+ keyIdentity,
336
+ [zcodeTokenForIdentity ?? "", businessToken].filter(Boolean),
337
+ signal,
338
+ );
339
+ return credentialsFromApiKey(apiKey, upstreamZaiAccess, identity);
225
340
  }
226
341
 
227
342
  async function exchangeGlmZcodeCode(
@@ -229,7 +344,6 @@ async function exchangeGlmZcodeCode(
229
344
  input: { code: string; state: string; redirectUri: string },
230
345
  signal: AbortSignal | undefined,
231
346
  ): Promise<OAuthCredentials> {
232
- // Defensive: a pasted value may still be a full redirect URL or `code#state`.
233
347
  const parsed = parseCallbackInput(input.code);
234
348
  const code = parsed.code ?? input.code;
235
349
  const brokerUrl = validateHttpsEndpoint(resolveBrokerTokenUrl(), "broker");
@@ -240,17 +354,8 @@ async function exchangeGlmZcodeCode(
240
354
  "broker",
241
355
  signal,
242
356
  );
243
- const { zcodeToken, upstreamZaiAccess, expiresIn } = parseBrokerResponse(brokerPayload);
244
- const identity = await resolveIdentity(fetchImpl, upstreamZaiAccess, [zcodeToken, upstreamZaiAccess], signal);
245
- // access = ZCode JWT (the coding-plan model credential); refresh = upstream
246
- // Z.AI token (identity only — there is no documented JWT refresh grant).
247
- return {
248
- access: zcodeToken,
249
- refresh: upstreamZaiAccess,
250
- expires: Date.now() + expiresIn * 1000 - GLM_ZCODE_REFRESH_SKEW_MS,
251
- email: identity.email,
252
- accountId: identity.accountId,
253
- };
357
+ const { upstreamZaiAccess, zcodeToken } = parseBrokerResponse(brokerPayload);
358
+ return provisionFromUpstream(fetchImpl, upstreamZaiAccess, zcodeToken, signal);
254
359
  }
255
360
 
256
361
  export interface GlmZcodeOAuthFlowOptions {
@@ -262,8 +367,6 @@ export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
262
367
 
263
368
  constructor(ctrl: OAuthController, options: GlmZcodeOAuthFlowOptions = {}) {
264
369
  super(ctrl, {
265
- // Port 0 → a free random local port. The custom-protocol redirect
266
- // never reaches it; login completes via manual code/redirect paste.
267
370
  preferredPort: 0,
268
371
  callbackPath: "/callback",
269
372
  callbackHostname: "127.0.0.1",
@@ -306,16 +409,25 @@ export interface GlmZcodeRefreshOptions {
306
409
  }
307
410
 
308
411
  /**
309
- * The ZCode session JWT is the model credential. ZCode mints it from a one-time
310
- * authorization code via the broker and exposes no documented refresh grant, so
311
- * there is no autonomous refresh: an expired credential requires re-login
312
- * (`/login glm-zcode`). Never return an expired credential as valid.
412
+ * Re-provision the Z.AI API key from the stored upstream token. The key itself is
413
+ * long-lived, so this is rarely needed; if the upstream token has expired it fails
414
+ * loudly and the user must re-login.
313
415
  */
314
416
  export async function refreshGlmZcodeToken(
315
- _credentials: OAuthCredentials,
316
- _options: AbortSignal | GlmZcodeRefreshOptions = {},
417
+ credentials: OAuthCredentials,
418
+ options: AbortSignal | GlmZcodeRefreshOptions = {},
317
419
  ): Promise<OAuthCredentials> {
318
- throw new Error(
319
- "glm-zcode session expired; re-login required (`/login glm-zcode`). The ZCode coding-plan token has no documented refresh endpoint.",
320
- );
420
+ const { signal, fetch: fetchImpl } =
421
+ options instanceof AbortSignal ? { signal: options, fetch: undefined } : options;
422
+ const upstream = credentials.refresh;
423
+ if (!upstream) {
424
+ throw new Error("glm-zcode credentials require re-login (`/login glm-zcode`); no stored upstream Z.AI token");
425
+ }
426
+ try {
427
+ return await provisionFromUpstream(fetchImpl ?? globalThis.fetch, upstream, undefined, signal);
428
+ } catch (error) {
429
+ throw new Error(
430
+ `glm-zcode credentials require re-login (\`/login glm-zcode\`); re-provisioning the Z.AI API key failed (${redactSecrets(String(error))})`,
431
+ );
432
+ }
321
433
  }