@gajae-code/ai 0.7.0 → 0.7.2
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 +12 -0
- package/dist/types/providers/anthropic.d.ts +15 -0
- package/dist/types/providers/mock.d.ts +2 -0
- package/dist/types/providers/openai-responses-shared.d.ts +16 -1
- package/dist/types/types.d.ts +8 -0
- package/dist/types/utils/json-parse.d.ts +8 -0
- package/dist/types/utils/oauth/glm-zcode.d.ts +33 -42
- package/package.json +2 -2
- package/src/models.json +1 -8
- package/src/provider-models/openai-compat.ts +1 -1
- package/src/providers/anthropic.ts +80 -0
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-codex-responses.ts +14 -2
- package/src/providers/openai-completions.ts +12 -1
- package/src/providers/openai-responses-shared.ts +43 -1
- package/src/types.ts +8 -0
- package/src/utils/json-parse.ts +18 -0
- package/src/utils/oauth/glm-zcode.ts +219 -107
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.2] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Reject truncated or incomplete streamed tool calls instead of executing them with partial arguments, so a cut-off tool-call payload fails fast rather than running against a mismatched schema.
|
|
10
|
+
|
|
11
|
+
## [0.7.1] - 2026-06-23
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- 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).
|
|
16
|
+
|
|
5
17
|
## [0.7.0] - 2026-06-22
|
|
6
18
|
|
|
7
19
|
### 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";
|
|
@@ -60,6 +60,8 @@ export type MockContent = string | {
|
|
|
60
60
|
name: string;
|
|
61
61
|
/** Object form is preferred; strings are passed through verbatim. */
|
|
62
62
|
arguments: Record<string, unknown> | string;
|
|
63
|
+
/** Simulate a provider-flagged truncated call (cut off mid-arguments). */
|
|
64
|
+
incompleteArguments?: boolean;
|
|
63
65
|
};
|
|
64
66
|
/** One scripted response. */
|
|
65
67
|
export interface MockResponse {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
2
|
import type { ResponseInput, ResponseInputContent, ResponseOutputItem } from "openai/resources/responses/responses";
|
|
3
|
-
import { type Api, type AssistantMessage, type ImageContent, type Model, type ServiceTier, type StopReason, type StreamOptions, type TextContent, type TextSignatureV1, type ToolResultMessage } from "../types";
|
|
3
|
+
import { type Api, type AssistantMessage, type ImageContent, type Model, type ServiceTier, type StopReason, type StreamOptions, type TextContent, type TextSignatureV1, type ToolCall, type ToolResultMessage } from "../types";
|
|
4
4
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
|
5
5
|
export declare function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string;
|
|
6
6
|
export declare function parseTextSignature(signature: string | undefined): {
|
|
@@ -44,6 +44,21 @@ export interface ProcessResponsesStreamOptions {
|
|
|
44
44
|
onOutputItemDone?: (item: ResponseOutputItem) => void;
|
|
45
45
|
}
|
|
46
46
|
export declare function processResponsesStream<TApi extends Api>(openaiStream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<TApi>, options?: ProcessResponsesStreamOptions): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Mark tool-call blocks left incomplete by a length-truncated response so the
|
|
49
|
+
* agent loop rejects them instead of executing a best-effort partial parse.
|
|
50
|
+
*
|
|
51
|
+
* The universal signal is finalization: a call that never received its terminal
|
|
52
|
+
* `output_item.done` (passed in via `isFinalized`) was cut off mid-arguments.
|
|
53
|
+
* This covers both JSON function calls and raw-input custom tools without
|
|
54
|
+
* mis-flagging a *completed* custom tool whose raw input is not valid JSON. As a
|
|
55
|
+
* defensive secondary, a finalized JSON function call whose buffered arguments
|
|
56
|
+
* still don't parse (e.g. a misbehaving relay) is flagged too. No-op unless the
|
|
57
|
+
* turn stopped for length.
|
|
58
|
+
*
|
|
59
|
+
* Shared by both Responses providers (`openai-responses`, `openai-codex-responses`).
|
|
60
|
+
*/
|
|
61
|
+
export declare function flagTruncatedToolCalls(output: AssistantMessage, stopReason: StopReason, isFinalized: (block: ToolCall) => boolean): void;
|
|
47
62
|
export declare function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason;
|
|
48
63
|
/** Initial empty `AssistantMessage` that streaming providers accumulate into. */
|
|
49
64
|
export declare function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -338,6 +338,14 @@ export interface ToolCall {
|
|
|
338
338
|
* JSON function tools.
|
|
339
339
|
*/
|
|
340
340
|
customWireName?: string;
|
|
341
|
+
/**
|
|
342
|
+
* Set when the provider detected the argument JSON was truncated — the model
|
|
343
|
+
* hit its output-token limit (or the response was otherwise cut short) before
|
|
344
|
+
* emitting a complete arguments object. The `arguments` field then holds a
|
|
345
|
+
* best-effort partial parse and must not be executed as-is; the agent loop
|
|
346
|
+
* rejects the call with a retryable error instead.
|
|
347
|
+
*/
|
|
348
|
+
incompleteArguments?: boolean;
|
|
341
349
|
}
|
|
342
350
|
export interface Usage {
|
|
343
351
|
/** Non-cached input tokens (matches the bucket the provider bills as new input). */
|
|
@@ -8,3 +8,11 @@ export declare function parseJsonWithRepair<T>(json: string): T;
|
|
|
8
8
|
* @returns Parsed object or empty object if parsing fails
|
|
9
9
|
*/
|
|
10
10
|
export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a string is a complete, well-formed JSON document (strict parse, no
|
|
13
|
+
* repair). Used to distinguish a tool-call argument blob that finished cleanly
|
|
14
|
+
* from one that was cut off mid-stream (truncation). An empty / whitespace-only
|
|
15
|
+
* string is treated as complete: a tool invoked with no arguments legitimately
|
|
16
|
+
* streams an empty buffer and must not be flagged as truncated.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isCompleteJson(text: string | undefined): boolean;
|
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
|
|
3
3
|
*
|
|
4
|
-
* Replicates the
|
|
5
|
-
* is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
|
|
6
|
-
* broker, and a custom-protocol redirect. It may break at any time and may
|
|
7
|
-
* violate ZCode/Z.AI Terms of Service. Endpoints
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
17
|
-
* - `access` = the **
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
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.
|
|
4
|
+
"version": "0.7.2",
|
|
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.
|
|
46
|
+
"@gajae-code/utils": "0.7.2",
|
|
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://
|
|
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://
|
|
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") {
|
package/src/providers/mock.ts
CHANGED
|
@@ -73,6 +73,8 @@ export type MockContent =
|
|
|
73
73
|
name: string;
|
|
74
74
|
/** Object form is preferred; strings are passed through verbatim. */
|
|
75
75
|
arguments: Record<string, unknown> | string;
|
|
76
|
+
/** Simulate a provider-flagged truncated call (cut off mid-arguments). */
|
|
77
|
+
incompleteArguments?: boolean;
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
/** One scripted response. */
|
|
@@ -416,6 +418,7 @@ function normalizeContent(input: MockContent, state: MockModel): TextContent | T
|
|
|
416
418
|
id: input.id ?? generateToolCallId(state),
|
|
417
419
|
name: input.name,
|
|
418
420
|
arguments: typeof input.arguments === "string" ? input.arguments : { ...input.arguments },
|
|
421
|
+
...(input.incompleteArguments ? { incompleteArguments: true } : {}),
|
|
419
422
|
} as ToolCall;
|
|
420
423
|
}
|
|
421
424
|
return input;
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
convertResponsesInputContent,
|
|
79
79
|
encodeResponsesToolCallId,
|
|
80
80
|
encodeTextSignatureV1,
|
|
81
|
+
flagTruncatedToolCalls,
|
|
81
82
|
mapOpenAIResponsesStopReason,
|
|
82
83
|
populateResponsesUsageFromResponse,
|
|
83
84
|
} from "./openai-responses-shared";
|
|
@@ -254,6 +255,8 @@ interface CodexStreamRuntime {
|
|
|
254
255
|
providerRetryAttempt: number;
|
|
255
256
|
sawTerminalEvent: boolean;
|
|
256
257
|
canSafelyReplayWebsocketOverSse: boolean;
|
|
258
|
+
/** Ids of tool calls that received their terminal `output_item.done`. */
|
|
259
|
+
finalizedToolCallIds: Set<string>;
|
|
257
260
|
}
|
|
258
261
|
|
|
259
262
|
interface CodexStreamProcessingContext {
|
|
@@ -910,6 +913,7 @@ function createCodexStreamRuntime(initial: {
|
|
|
910
913
|
providerRetryAttempt: 0,
|
|
911
914
|
sawTerminalEvent: false,
|
|
912
915
|
canSafelyReplayWebsocketOverSse: true,
|
|
916
|
+
finalizedToolCallIds: new Set<string>(),
|
|
913
917
|
};
|
|
914
918
|
}
|
|
915
919
|
|
|
@@ -1267,9 +1271,11 @@ function handleOutputItemDone(
|
|
|
1267
1271
|
}
|
|
1268
1272
|
|
|
1269
1273
|
if (item.type === "function_call") {
|
|
1274
|
+
const id = encodeResponsesToolCallId(item.call_id, item.id);
|
|
1275
|
+
runtime.finalizedToolCallIds.add(id);
|
|
1270
1276
|
const toolCall: ToolCall = {
|
|
1271
1277
|
type: "toolCall",
|
|
1272
|
-
id
|
|
1278
|
+
id,
|
|
1273
1279
|
name: item.name,
|
|
1274
1280
|
arguments: parseStreamingJson(item.arguments || "{}"),
|
|
1275
1281
|
};
|
|
@@ -1279,13 +1285,15 @@ function handleOutputItemDone(
|
|
|
1279
1285
|
}
|
|
1280
1286
|
|
|
1281
1287
|
if (item.type === "custom_tool_call") {
|
|
1288
|
+
const id = encodeResponsesToolCallId(item.call_id, item.id);
|
|
1289
|
+
runtime.finalizedToolCallIds.add(id);
|
|
1282
1290
|
const rawInput =
|
|
1283
1291
|
runtime.currentBlock?.type === "toolCall" && runtime.currentBlock.partialJson
|
|
1284
1292
|
? runtime.currentBlock.partialJson
|
|
1285
1293
|
: (item.input ?? "");
|
|
1286
1294
|
const toolCall: ToolCall = {
|
|
1287
1295
|
type: "toolCall",
|
|
1288
|
-
id
|
|
1296
|
+
id,
|
|
1289
1297
|
name: item.name,
|
|
1290
1298
|
arguments: { input: rawInput },
|
|
1291
1299
|
customWireName: item.name,
|
|
@@ -1349,6 +1357,10 @@ function handleResponseCompleted(
|
|
|
1349
1357
|
calculateCost(model, output.usage);
|
|
1350
1358
|
applyCodexServiceTierPricing(model, output.usage, response?.service_tier, runtime.requestBodyForState.service_tier);
|
|
1351
1359
|
output.stopReason = mapOpenAIResponsesStopReason(response?.status as OpenAI.Responses.ResponseStatus | undefined);
|
|
1360
|
+
// A response cut short for length may have stopped mid-tool-call. Flag any
|
|
1361
|
+
// call that never received its `output_item.done` so the agent loop rejects
|
|
1362
|
+
// the truncated arguments instead of executing a best-effort partial parse.
|
|
1363
|
+
flagTruncatedToolCalls(output, output.stopReason, block => runtime.finalizedToolCallIds.has(block.id));
|
|
1352
1364
|
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
|
|
1353
1365
|
output.stopReason = "toolUse";
|
|
1354
1366
|
}
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
getStreamFirstEventTimeoutMs,
|
|
52
52
|
iterateWithIdleTimeout,
|
|
53
53
|
} from "../utils/idle-iterator";
|
|
54
|
-
import { parseStreamingJson } from "../utils/json-parse";
|
|
54
|
+
import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
|
|
55
55
|
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
|
56
56
|
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
|
57
57
|
import { notifyProviderResponse } from "../utils/provider-response";
|
|
@@ -889,6 +889,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
889
889
|
}
|
|
890
890
|
}
|
|
891
891
|
|
|
892
|
+
// A turn cut short for length may have stopped mid-tool-call. The open
|
|
893
|
+
// block's `partialArgs` would otherwise be repaired into a plausible-
|
|
894
|
+
// but-wrong object by `finishCurrentBlock`; flag it first so the agent
|
|
895
|
+
// loop rejects the truncated call instead of executing it.
|
|
896
|
+
if (output.stopReason === "length" && currentBlock?.type === "toolCall") {
|
|
897
|
+
const partial = (currentBlock as { partialArgs?: string }).partialArgs;
|
|
898
|
+
if (partial !== undefined && !isCompleteJson(partial)) {
|
|
899
|
+
currentBlock.incompleteArguments = true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
892
903
|
finishCurrentBlock(currentBlock);
|
|
893
904
|
|
|
894
905
|
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../types";
|
|
31
31
|
import { normalizeResponsesToolCallId } from "../utils";
|
|
32
32
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
|
33
|
-
import { parseStreamingJson } from "../utils/json-parse";
|
|
33
|
+
import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
|
|
34
34
|
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
|
|
35
35
|
|
|
36
36
|
export function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
|
|
@@ -710,6 +710,13 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
710
710
|
: "Unknown error (no error details in response)";
|
|
711
711
|
throw new Error(message);
|
|
712
712
|
}
|
|
713
|
+
// A response cut short for length (`incomplete`) may have stopped
|
|
714
|
+
// mid-tool-call. Any tool-call item still tracked in `items` never
|
|
715
|
+
// received its terminal `output_item.done`, so it was cut off; flag it
|
|
716
|
+
// (along with any finalized-but-unparseable JSON call) so the agent loop
|
|
717
|
+
// rejects it instead of executing repaired/partial arguments.
|
|
718
|
+
const openBlocks = new Set<unknown>(Array.from(items.values(), entry => entry.block));
|
|
719
|
+
flagTruncatedToolCalls(output, output.stopReason, block => !openBlocks.has(block));
|
|
713
720
|
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
|
|
714
721
|
output.stopReason = "toolUse";
|
|
715
722
|
}
|
|
@@ -728,6 +735,41 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
728
735
|
}
|
|
729
736
|
}
|
|
730
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Mark tool-call blocks left incomplete by a length-truncated response so the
|
|
740
|
+
* agent loop rejects them instead of executing a best-effort partial parse.
|
|
741
|
+
*
|
|
742
|
+
* The universal signal is finalization: a call that never received its terminal
|
|
743
|
+
* `output_item.done` (passed in via `isFinalized`) was cut off mid-arguments.
|
|
744
|
+
* This covers both JSON function calls and raw-input custom tools without
|
|
745
|
+
* mis-flagging a *completed* custom tool whose raw input is not valid JSON. As a
|
|
746
|
+
* defensive secondary, a finalized JSON function call whose buffered arguments
|
|
747
|
+
* still don't parse (e.g. a misbehaving relay) is flagged too. No-op unless the
|
|
748
|
+
* turn stopped for length.
|
|
749
|
+
*
|
|
750
|
+
* Shared by both Responses providers (`openai-responses`, `openai-codex-responses`).
|
|
751
|
+
*/
|
|
752
|
+
export function flagTruncatedToolCalls(
|
|
753
|
+
output: AssistantMessage,
|
|
754
|
+
stopReason: StopReason,
|
|
755
|
+
isFinalized: (block: ToolCall) => boolean,
|
|
756
|
+
): void {
|
|
757
|
+
if (stopReason !== "length") return;
|
|
758
|
+
for (const block of output.content) {
|
|
759
|
+
if (block.type !== "toolCall") continue;
|
|
760
|
+
if (!isFinalized(block)) {
|
|
761
|
+
block.incompleteArguments = true;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
// Finalized: custom tools carry raw (non-JSON) input and are complete once
|
|
765
|
+
// finalized; only JSON function calls get the parse double-check.
|
|
766
|
+
if (!block.customWireName) {
|
|
767
|
+
const partial = (block as { partialJson?: string }).partialJson;
|
|
768
|
+
if (partial !== undefined && !isCompleteJson(partial)) block.incompleteArguments = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
731
773
|
export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason {
|
|
732
774
|
if (!status) return "stop";
|
|
733
775
|
switch (status) {
|
package/src/types.ts
CHANGED
|
@@ -490,6 +490,14 @@ export interface ToolCall {
|
|
|
490
490
|
* JSON function tools.
|
|
491
491
|
*/
|
|
492
492
|
customWireName?: string;
|
|
493
|
+
/**
|
|
494
|
+
* Set when the provider detected the argument JSON was truncated — the model
|
|
495
|
+
* hit its output-token limit (or the response was otherwise cut short) before
|
|
496
|
+
* emitting a complete arguments object. The `arguments` field then holds a
|
|
497
|
+
* best-effort partial parse and must not be executed as-is; the agent loop
|
|
498
|
+
* rejects the call with a retryable error instead.
|
|
499
|
+
*/
|
|
500
|
+
incompleteArguments?: boolean;
|
|
493
501
|
}
|
|
494
502
|
|
|
495
503
|
export interface Usage {
|
package/src/utils/json-parse.ts
CHANGED
|
@@ -146,3 +146,21 @@ export function parseStreamingJson<T = Record<string, unknown>>(partialJson: str
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a string is a complete, well-formed JSON document (strict parse, no
|
|
152
|
+
* repair). Used to distinguish a tool-call argument blob that finished cleanly
|
|
153
|
+
* from one that was cut off mid-stream (truncation). An empty / whitespace-only
|
|
154
|
+
* string is treated as complete: a tool invoked with no arguments legitimately
|
|
155
|
+
* streams an empty buffer and must not be flagged as truncated.
|
|
156
|
+
*/
|
|
157
|
+
export function isCompleteJson(text: string | undefined): boolean {
|
|
158
|
+
const trimmed = text?.trim();
|
|
159
|
+
if (!trimmed) return true;
|
|
160
|
+
try {
|
|
161
|
+
JSON.parse(trimmed);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -1,56 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
|
|
3
3
|
*
|
|
4
|
-
* Replicates the
|
|
5
|
-
* is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
|
|
6
|
-
* broker, and a custom-protocol redirect. It may break at any time and may
|
|
7
|
-
* violate ZCode/Z.AI Terms of Service. Endpoints
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
17
|
-
* - `access` = the **
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
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/
|
|
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
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
303
|
+
// fall through
|
|
204
304
|
}
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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 {
|
|
244
|
-
|
|
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
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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
|
-
|
|
316
|
-
|
|
417
|
+
credentials: OAuthCredentials,
|
|
418
|
+
options: AbortSignal | GlmZcodeRefreshOptions = {},
|
|
317
419
|
): Promise<OAuthCredentials> {
|
|
318
|
-
|
|
319
|
-
|
|
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
|
}
|