@agent-proxy/zcode 0.0.0-stage → 0.11.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/LICENSE +21 -0
- package/README.md +19 -2
- package/dist/catalog.d.ts +30 -0
- package/dist/catalog.js +150 -0
- package/dist/client-signing.d.ts +46 -0
- package/dist/client-signing.js +225 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +944 -0
- package/dist/credential-cipher.d.ts +2 -0
- package/dist/credential-cipher.js +43 -0
- package/dist/endpoint-routing.d.ts +33 -0
- package/dist/endpoint-routing.js +147 -0
- package/dist/fingerprint-data.json +12 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +648 -0
- package/dist/individual-credentials.d.ts +19 -0
- package/dist/individual-credentials.js +39 -0
- package/dist/plans.d.ts +19 -0
- package/dist/plans.js +74 -0
- package/dist/request-context.d.ts +39 -0
- package/dist/request-context.js +213 -0
- package/dist/request.d.ts +5 -0
- package/dist/request.js +414 -0
- package/dist/resources/models.js +3854 -0
- package/dist/response.d.ts +27 -0
- package/dist/response.js +725 -0
- package/dist/team-credentials.d.ts +12 -0
- package/dist/team-credentials.js +123 -0
- package/dist/vision-template.json +1 -0
- package/dist/vision.d.ts +62 -0
- package/dist/vision.js +204 -0
- package/dist/wire.d.ts +23 -0
- package/dist/wire.js +25 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codex-cliproxy-gateway contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agent-proxy/zcode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/aceHubert/codex-cliproxy-gateway/actions/workflows/deploy.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@agent-proxy/zcode)
|
|
5
|
+
|
|
6
|
+
Agent Proxy 的 ZCode 插件,组合本地配置、套餐与 API Key 模型目录,
|
|
7
|
+
处理 ZCode 模型路由和请求、响应转换。
|
|
8
|
+
通过 `createZcodePlugin` 接入宿主服务。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bun add @agent-proxy/zcode
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
插件由 [@agent-proxy/gateway](https://www.npmjs.com/package/@agent-proxy/gateway)
|
|
17
|
+
显式装配,并依赖宿主注入 `HostServices`;使用前需准备可用的 ZCode 配置。
|
|
18
|
+
模型预设已内联到发布产物,无需安装私有 `@agent-proxy/models` 包。
|
|
19
|
+
|
|
20
|
+
[项目源码与文档](https://github.com/aceHubert/codex-cliproxy-gateway)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ModelCatalog } from "@agent-proxy/plugin-api";
|
|
2
|
+
import type { ZcodeProviderSnapshot, ZcodeSelection } from "./config.ts";
|
|
3
|
+
export declare function isZcodeModel(model: unknown): boolean;
|
|
4
|
+
/** ZCode 对外模型 ID 的固定前缀族:旧 API Key 命名空间与各套餐作用域前缀。
|
|
5
|
+
* API Key 多 provider 的前缀(zcode-<providerId>/)由 provider ID 动态生成,不在此列。 */
|
|
6
|
+
export declare function zcodeStaticModelPrefixes(): string[];
|
|
7
|
+
/** 从对外模型 ID 解析套餐连接形态;未声明的 zcode- 段不属于任何套餐。 */
|
|
8
|
+
export declare function zcodeModelPlan(model: string): ZcodeSelection["kind"] | undefined;
|
|
9
|
+
export declare function zcodeAPIProviderPrefix(providerID: string): string;
|
|
10
|
+
/** 从 `zcode-<providerId>/model` 提取 provider ID;URL 编码用于容忍特殊 ID。 */
|
|
11
|
+
export declare function zcodeAPIProviderIDFromModel(model: string): string | undefined;
|
|
12
|
+
/** 目录使用厂商规范名称,上游使用当前套餐配置中的原始模型拼写。 */
|
|
13
|
+
export declare function zcodeUpstreamModel(model: string, snapshot: ZcodeProviderSnapshot): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* 仅由模型包的厂商预设合成全量裸 ID 目录。纯内存计算,
|
|
16
|
+
* 重建成本与模型条数同级,不需要磁盘缓存,也不携带任何套餐或授权信息。
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildZcodeVendorCatalog(): ModelCatalog;
|
|
19
|
+
/** /models 只按快照套餐的支持集合筛选并加套餐作用域前缀,不重新合成厂商元数据。 */
|
|
20
|
+
export declare function createZcodeCatalog(snapshot: ZcodeProviderSnapshot, cached: ModelCatalog): ModelCatalog;
|
|
21
|
+
/** 按套餐支持集合(或多个套餐/entitlement 的能力并集)合成带套餐作用域前缀的目录。 */
|
|
22
|
+
export declare function createZcodeCatalogForModels(modelIds: readonly string[], cached: ModelCatalog, plan?: ZcodeSelection["kind"], apiProviderID?: string, apiProviderName?: string): ModelCatalog;
|
|
23
|
+
/** 启用 ZCode 时保留 zcode/ 顶层命名空间,防止上游同名条目与 ZCode 目录冲突。 */
|
|
24
|
+
export declare function mergeZcodeCatalog(base: ModelCatalog, zcode: ModelCatalog): ModelCatalog;
|
|
25
|
+
/**
|
|
26
|
+
* 把当前套餐的对外目录落盘。watch 驱动的账号/套餐切换与启动首读都会经过这里,
|
|
27
|
+
* `/v1/models` 只消费内存中的同一份结果;配置失效时写入空 models,不留旧套餐模型。
|
|
28
|
+
* 内容未变化时不写盘,返回是否发生了实际变更。
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeZcodeServedCatalog(file: string, catalog: ModelCatalog): boolean;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getVendorModelPresets } from "./resources/models.js";
|
|
4
|
+
import { compileModelOverrides, synthesizeModelEntry } from "@agent-proxy/plugin-api";
|
|
5
|
+
import { atomicWrite } from "@agent-proxy/plugin-api";
|
|
6
|
+
const vendorModelIds = () => (getVendorModelPresets()["z.ai"] ?? []).map((model) => model.name);
|
|
7
|
+
/** 旧版单 API Key 命名空间;新版多 Key 目录不再生成该前缀。 */
|
|
8
|
+
const ZCODE_PREFIX = "zcode/";
|
|
9
|
+
/** 套餐作用域前缀;API Key 因可有多个 provider,前缀由 provider ID 动态生成。 */
|
|
10
|
+
const PLAN_PREFIXES = {
|
|
11
|
+
"individual-coding-plan": "zcode-individual-coding-plan/",
|
|
12
|
+
"team-coding-plan": "zcode-team-coding-plan/",
|
|
13
|
+
"start-plan": "zcode-start-plan/",
|
|
14
|
+
};
|
|
15
|
+
/** 显示名括号内与 ZCode 客户端模型选择器的套餐分组(个人/团队/免费)一致。 */
|
|
16
|
+
const PLAN_DISPLAY_LABELS = {
|
|
17
|
+
"individual-coding-plan": "个人",
|
|
18
|
+
"team-coding-plan": "团队",
|
|
19
|
+
"start-plan": "免费",
|
|
20
|
+
};
|
|
21
|
+
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
22
|
+
export function isZcodeModel(model) {
|
|
23
|
+
return typeof model === "string" && /^zcode(?:\/|-)/i.test(model);
|
|
24
|
+
}
|
|
25
|
+
/** ZCode 对外模型 ID 的固定前缀族:旧 API Key 命名空间与各套餐作用域前缀。
|
|
26
|
+
* API Key 多 provider 的前缀(zcode-<providerId>/)由 provider ID 动态生成,不在此列。 */
|
|
27
|
+
export function zcodeStaticModelPrefixes() {
|
|
28
|
+
return [ZCODE_PREFIX, ...Object.values(PLAN_PREFIXES)].filter((prefix) => typeof prefix === "string");
|
|
29
|
+
}
|
|
30
|
+
/** 从对外模型 ID 解析套餐连接形态;未声明的 zcode- 段不属于任何套餐。 */
|
|
31
|
+
export function zcodeModelPlan(model) {
|
|
32
|
+
if (!isZcodeModel(model))
|
|
33
|
+
return undefined;
|
|
34
|
+
const id = model.toLowerCase();
|
|
35
|
+
for (const [kind, prefix] of Object.entries(PLAN_PREFIXES)) {
|
|
36
|
+
if (id.startsWith(prefix))
|
|
37
|
+
return kind;
|
|
38
|
+
}
|
|
39
|
+
return id.startsWith(ZCODE_PREFIX) || /^zcode-[^/]+\//.test(id) ? "api-key" : undefined;
|
|
40
|
+
}
|
|
41
|
+
export function zcodeAPIProviderPrefix(providerID) {
|
|
42
|
+
return `zcode-${encodeURIComponent(providerID)}/`;
|
|
43
|
+
}
|
|
44
|
+
/** 从 `zcode-<providerId>/model` 提取 provider ID;URL 编码用于容忍特殊 ID。 */
|
|
45
|
+
export function zcodeAPIProviderIDFromModel(model) {
|
|
46
|
+
if (zcodeModelPlan(model) !== "api-key")
|
|
47
|
+
return undefined;
|
|
48
|
+
const match = /^zcode-([^/]+)\//i.exec(model);
|
|
49
|
+
if (!match)
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(match[1]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** 目录使用厂商规范名称,上游使用当前套餐配置中的原始模型拼写。 */
|
|
59
|
+
export function zcodeUpstreamModel(model, snapshot) {
|
|
60
|
+
const plan = zcodeModelPlan(model);
|
|
61
|
+
// 套餐段必须与快照的连接形态一致:会话选了哪个套餐,就只能用该套餐的模型拼写。
|
|
62
|
+
if (!plan || plan !== snapshot.plan)
|
|
63
|
+
return;
|
|
64
|
+
const prefix = plan === "api-key"
|
|
65
|
+
? model.toLowerCase().startsWith(zcodeAPIProviderPrefix(snapshot.providerID))
|
|
66
|
+
? zcodeAPIProviderPrefix(snapshot.providerID)
|
|
67
|
+
: ZCODE_PREFIX
|
|
68
|
+
: PLAN_PREFIXES[plan];
|
|
69
|
+
if (!model.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
70
|
+
return;
|
|
71
|
+
const id = model.slice(prefix.length).toLowerCase();
|
|
72
|
+
if (!id)
|
|
73
|
+
return;
|
|
74
|
+
if (!vendorModelIds().some((candidate) => candidate.toLowerCase() === id))
|
|
75
|
+
return;
|
|
76
|
+
return snapshot.modelIds.find((candidate) => candidate.toLowerCase() === id);
|
|
77
|
+
}
|
|
78
|
+
/** 归一化显示名:剥掉历史 ZCode 前缀/后缀,确保渠道标识只由投影层统一添加。 */
|
|
79
|
+
function stripZcodeChannelLabel(name) {
|
|
80
|
+
return name
|
|
81
|
+
.replace(/^ZCode\//, "")
|
|
82
|
+
.replace(/ \(ZCode[^)]*\)$/, "")
|
|
83
|
+
.replace(/(ZCode[^)]*)$/, "");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 仅由模型包的厂商预设合成全量裸 ID 目录。纯内存计算,
|
|
87
|
+
* 重建成本与模型条数同级,不需要磁盘缓存,也不携带任何套餐或授权信息。
|
|
88
|
+
*/
|
|
89
|
+
export function buildZcodeVendorCatalog() {
|
|
90
|
+
const vendorModels = getVendorModelPresets();
|
|
91
|
+
const modelIds = (vendorModels["z.ai"] ?? []).map((model) => model.name);
|
|
92
|
+
const vendors = compileModelOverrides(vendorModels, "vendor_models.json");
|
|
93
|
+
const preset = { models: modelIds.map((id, index) => synthesizeModelEntry(`z.ai/${id}`, index, { models: [] }, vendors)) };
|
|
94
|
+
return { models: preset.models.map((entry) => {
|
|
95
|
+
const slug = entry.slug.slice("z.ai/".length);
|
|
96
|
+
return {
|
|
97
|
+
...entry,
|
|
98
|
+
slug,
|
|
99
|
+
display_name: entry.display_name ?? slug,
|
|
100
|
+
prefer_websockets: false,
|
|
101
|
+
// zcode 链路已把 Responses web_search 原生映射为 web_search_20250305(实测 z.ai 支持)。
|
|
102
|
+
supports_search_tool: true,
|
|
103
|
+
};
|
|
104
|
+
}) };
|
|
105
|
+
}
|
|
106
|
+
/** /models 只按快照套餐的支持集合筛选并加套餐作用域前缀,不重新合成厂商元数据。 */
|
|
107
|
+
export function createZcodeCatalog(snapshot, cached) {
|
|
108
|
+
return createZcodeCatalogForModels(snapshot.modelIds, cached, snapshot.plan, snapshot.plan === "api-key" ? snapshot.providerID : undefined, snapshot.providerName);
|
|
109
|
+
}
|
|
110
|
+
/** 按套餐支持集合(或多个套餐/entitlement 的能力并集)合成带套餐作用域前缀的目录。 */
|
|
111
|
+
export function createZcodeCatalogForModels(modelIds, cached, plan = "api-key", apiProviderID, apiProviderName) {
|
|
112
|
+
const supported = new Set(modelIds.map((model) => model.toLowerCase()));
|
|
113
|
+
const prefix = plan === "api-key" && apiProviderID
|
|
114
|
+
? zcodeAPIProviderPrefix(apiProviderID)
|
|
115
|
+
: PLAN_PREFIXES[plan] ?? ZCODE_PREFIX;
|
|
116
|
+
// 渠道统一用 ZCode/ 前缀标注(与 cliproxy/new-api 的同名条目区分),括号内只保留
|
|
117
|
+
// 套餐分组或多 Key 的 provider 名,不再重复 ZCode 字样。
|
|
118
|
+
const qualifier = plan === "api-key"
|
|
119
|
+
? apiProviderID ? `(${apiProviderName ?? apiProviderID})` : ""
|
|
120
|
+
: PLAN_DISPLAY_LABELS[plan] ? ` (${PLAN_DISPLAY_LABELS[plan]})` : "";
|
|
121
|
+
return { models: cached.models.filter((entry) => supported.has(entry.slug.toLowerCase())).map((entry) => ({
|
|
122
|
+
...entry,
|
|
123
|
+
slug: `${prefix}${entry.slug}`,
|
|
124
|
+
display_name: `ZCode/${stripZcodeChannelLabel(entry.display_name ?? entry.slug)}${qualifier}`,
|
|
125
|
+
})) };
|
|
126
|
+
}
|
|
127
|
+
/** 启用 ZCode 时保留 zcode/ 顶层命名空间,防止上游同名条目与 ZCode 目录冲突。 */
|
|
128
|
+
export function mergeZcodeCatalog(base, zcode) {
|
|
129
|
+
const models = base.models.filter((model) => !isZcodeModel(model.slug));
|
|
130
|
+
const priority = Math.max(0, ...models.map((model) => Number(model.priority) || 0)) + 100;
|
|
131
|
+
return { models: [...models, ...zcode.models.map((model, index) => ({ ...model, priority: priority + index }))] };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 把当前套餐的对外目录落盘。watch 驱动的账号/套餐切换与启动首读都会经过这里,
|
|
135
|
+
* `/v1/models` 只消费内存中的同一份结果;配置失效时写入空 models,不留旧套餐模型。
|
|
136
|
+
* 内容未变化时不写盘,返回是否发生了实际变更。
|
|
137
|
+
*/
|
|
138
|
+
export function writeZcodeServedCatalog(file, catalog) {
|
|
139
|
+
const contents = `${JSON.stringify({
|
|
140
|
+
content_hash: digest(catalog.models),
|
|
141
|
+
models: catalog.models,
|
|
142
|
+
}, null, 2)}\n`;
|
|
143
|
+
try {
|
|
144
|
+
if (fs.readFileSync(file, "utf8") === contents)
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
catch { /* 缺失或不可读时直接写入。 */ }
|
|
148
|
+
atomicWrite(file, contents);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** 业务 key 必须是恰好一个点分隔的 `{id}.{secret}`;账号 JWT(两个点)不适用。 */
|
|
2
|
+
export declare function parseClientSigningCredential(apiKey: string): {
|
|
3
|
+
apiKeyId: string;
|
|
4
|
+
apiKeySecret: string;
|
|
5
|
+
} | undefined;
|
|
6
|
+
/** PoW:对 `id\nappId\nsessionId\nts` 的 SHA-256 hex 前缀找 8 个前导零比特。 */
|
|
7
|
+
export declare function solveClientRequestPow(input: {
|
|
8
|
+
apiKeyId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
ts: string;
|
|
11
|
+
}, bits?: number): string;
|
|
12
|
+
/** 401 响应正文中的可刷新签名拒绝原因;与官方客户端同序检查 msg/data.reason/error.*。 */
|
|
13
|
+
export declare function clientSigningVerifyRejection(body: string): string | undefined;
|
|
14
|
+
export interface ZcodeClientSigningOptions {
|
|
15
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
randomHex?: (bytes: number) => string;
|
|
18
|
+
handshakeTimeoutMs?: number;
|
|
19
|
+
failureCooldownMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ZcodeClientSigningScope {
|
|
22
|
+
apiKey: string;
|
|
23
|
+
/** provider baseURL;握手 origin 与缓存键取自它(签名发生在端点重映射之前)。 */
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
clientVersion: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
}
|
|
28
|
+
export declare class ZcodeClientSigning {
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private readonly now;
|
|
31
|
+
private readonly randomHexImpl;
|
|
32
|
+
private readonly handshakeTimeoutMs;
|
|
33
|
+
private readonly failureCooldownMs;
|
|
34
|
+
private readonly keys;
|
|
35
|
+
private readonly retryAfter;
|
|
36
|
+
constructor(options?: ZcodeClientSigningOptions);
|
|
37
|
+
/**
|
|
38
|
+
* 就地补齐一个请求的签名头;返回是否签名成功。除缺少必要输入外,任何失败
|
|
39
|
+
* (网络、协议、密码学)都返回 false 并保持原头不动,由调用方按未签名继续。
|
|
40
|
+
*/
|
|
41
|
+
decorate(headers: Headers, scope: ZcodeClientSigningScope): Promise<boolean>;
|
|
42
|
+
/** 服务端拒绝(VERIFY_*)或密钥疑似轮换后作废缓存;下一次 decorate 重新握手。 */
|
|
43
|
+
invalidate(apiKey: string, baseUrl: string): void;
|
|
44
|
+
private beginHandshake;
|
|
45
|
+
private handshake;
|
|
46
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZCode Client Request Signing(coding plan 官方客户端签名)。
|
|
3
|
+
*
|
|
4
|
+
* 官方客户端对 coding plan 且官方域名的模型请求会附加一组逐请求签名头
|
|
5
|
+
* (X-App-Id/X-Client-Ts/X-Client-Version/X-Client-Sig/X-Client-Nonce/X-Client-Pow),
|
|
6
|
+
* 服务端据此把流量归因为官方 ZCode 客户端。签名身份通过握手建立:客户端用
|
|
7
|
+
* `{id}.{secret}` 形态的业务 key 派生 HKDF 密钥,向上游换取一份用该密钥加密的
|
|
8
|
+
* Ed25519 私钥,再对每个请求的时间戳/随机数做 Ed25519 签名与轻量 PoW。
|
|
9
|
+
*
|
|
10
|
+
* 本模块逐字节复刻该协议,全部输入来自网关已持有的业务 key;任何一步失败都
|
|
11
|
+
* fail-open:不加签名头,请求按未签名继续发送(与官方客户端 VERIFY_* 重试耗尽
|
|
12
|
+
* 后的 bypass 行为一致),绝不阻塞或断流。
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
const HANDSHAKE_PATH = "/api/paas/c1f3a7e2/v2/client";
|
|
16
|
+
const HANDSHAKE_ACTION = "get_sign_key";
|
|
17
|
+
const KDF_SALT = "WD_CLIENT_SIGN_KDF_SALT";
|
|
18
|
+
const HMAC_INFO = "getSignKey_hmac";
|
|
19
|
+
const PRIVATE_KEY_INFO = "ed25519_priv";
|
|
20
|
+
const APP_ID = "zcode";
|
|
21
|
+
const POW_BITS = 8;
|
|
22
|
+
const NONCE_BYTES = 16;
|
|
23
|
+
const POW_SALT_BYTES = 12;
|
|
24
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
25
|
+
const FAILURE_COOLDOWN_MS = 30_000;
|
|
26
|
+
const MAX_KEY_ENTRIES = 32;
|
|
27
|
+
const VERIFY_SIGNATURE_INVALID = "VERIFY_SIGNATURE_INVALID";
|
|
28
|
+
const VERIFY_APIKEY_EXPIRED = "VERIFY_APIKEY_EXPIRED";
|
|
29
|
+
/** 业务 key 必须是恰好一个点分隔的 `{id}.{secret}`;账号 JWT(两个点)不适用。 */
|
|
30
|
+
export function parseClientSigningCredential(apiKey) {
|
|
31
|
+
const separator = apiKey.indexOf(".");
|
|
32
|
+
if (separator <= 0 || separator !== apiKey.lastIndexOf("."))
|
|
33
|
+
return undefined;
|
|
34
|
+
if (!apiKey.slice(0, separator).trim() || !apiKey.slice(separator + 1).trim())
|
|
35
|
+
return undefined;
|
|
36
|
+
return { apiKeyId: apiKey.slice(0, separator), apiKeySecret: apiKey.slice(separator + 1) };
|
|
37
|
+
}
|
|
38
|
+
/** 本地 TS 库把 TextEncoder 结果声明为 ArrayBufferLike;WebCrypto 形参需要 ArrayBuffer。 */
|
|
39
|
+
function encodeUtf8(value) {
|
|
40
|
+
const encoded = new TextEncoder().encode(value);
|
|
41
|
+
const bytes = new Uint8Array(encoded.byteLength);
|
|
42
|
+
bytes.set(encoded);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
function bytesToHex(bytes) {
|
|
46
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
}
|
|
48
|
+
function bytesToBase64(bytes) {
|
|
49
|
+
let binary = "";
|
|
50
|
+
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
51
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
52
|
+
}
|
|
53
|
+
return btoa(binary);
|
|
54
|
+
}
|
|
55
|
+
function base64ToBytes(value) {
|
|
56
|
+
if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
|
|
57
|
+
throw new Error("invalid base64");
|
|
58
|
+
const binary = atob(value);
|
|
59
|
+
const bytes = new Uint8Array(binary.length);
|
|
60
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
61
|
+
bytes[index] = binary.charCodeAt(index);
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
function randomHex(bytes) {
|
|
65
|
+
return bytesToHex(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
66
|
+
}
|
|
67
|
+
/** HKDF-SHA-256(ikm=secret, salt=固定盐, info, 32 字节);与官方客户端的派生参数一致。 */
|
|
68
|
+
async function deriveBytes(secret, info) {
|
|
69
|
+
const key = await crypto.subtle.importKey("raw", encodeUtf8(secret), "HKDF", false, ["deriveBits"]);
|
|
70
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: encodeUtf8(KDF_SALT), info: encodeUtf8(info) }, key, 256));
|
|
71
|
+
}
|
|
72
|
+
function leadingZeroBits(hash) {
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const byte of hash) {
|
|
75
|
+
if (byte === 0) {
|
|
76
|
+
total += 8;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let prefix = 0;
|
|
80
|
+
for (let mask = 0x80; mask > 0 && !(byte & mask); mask >>= 1)
|
|
81
|
+
prefix += 1;
|
|
82
|
+
return total + prefix;
|
|
83
|
+
}
|
|
84
|
+
return total;
|
|
85
|
+
}
|
|
86
|
+
/** PoW:对 `id\nappId\nsessionId\nts` 的 SHA-256 hex 前缀找 8 个前导零比特。 */
|
|
87
|
+
export function solveClientRequestPow(input, bits = POW_BITS) {
|
|
88
|
+
const prefix = createHash("sha256")
|
|
89
|
+
.update(`${input.apiKeyId}\n${APP_ID}\n${input.sessionId}\n${input.ts}`)
|
|
90
|
+
.digest("hex")
|
|
91
|
+
.slice(0, 32);
|
|
92
|
+
const salt = randomHex(POW_SALT_BYTES);
|
|
93
|
+
for (let counter = 0; counter <= 0xffffffff; counter += 1) {
|
|
94
|
+
const candidate = `${salt}${counter.toString(16).padStart(8, "0")}`;
|
|
95
|
+
const digest = createHash("sha256").update(`${prefix}\n${candidate}`).digest();
|
|
96
|
+
if (leadingZeroBits(digest) >= bits)
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
throw new Error("Unable to solve client request proof of work");
|
|
100
|
+
}
|
|
101
|
+
/** 401 响应正文中的可刷新签名拒绝原因;与官方客户端同序检查 msg/data.reason/error.*。 */
|
|
102
|
+
export function clientSigningVerifyRejection(body) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(body);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
111
|
+
return undefined;
|
|
112
|
+
const envelope = parsed;
|
|
113
|
+
const reasonOf = (value) => (value !== null && typeof value === "object" && !Array.isArray(value) ? value : {});
|
|
114
|
+
const data = reasonOf(envelope.data);
|
|
115
|
+
const error = reasonOf(envelope.error);
|
|
116
|
+
for (const candidate of [envelope.msg, data.reason, error.reason, error.message]) {
|
|
117
|
+
if (candidate === VERIFY_SIGNATURE_INVALID || candidate === VERIFY_APIKEY_EXPIRED)
|
|
118
|
+
return candidate;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
export class ZcodeClientSigning {
|
|
123
|
+
fetchImpl;
|
|
124
|
+
now;
|
|
125
|
+
randomHexImpl;
|
|
126
|
+
handshakeTimeoutMs;
|
|
127
|
+
failureCooldownMs;
|
|
128
|
+
keys = new Map();
|
|
129
|
+
retryAfter = new Map();
|
|
130
|
+
constructor(options = {}) {
|
|
131
|
+
this.fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
132
|
+
this.now = options.now ?? Date.now;
|
|
133
|
+
this.randomHexImpl = options.randomHex ?? randomHex;
|
|
134
|
+
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
135
|
+
this.failureCooldownMs = options.failureCooldownMs ?? FAILURE_COOLDOWN_MS;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 就地补齐一个请求的签名头;返回是否签名成功。除缺少必要输入外,任何失败
|
|
139
|
+
* (网络、协议、密码学)都返回 false 并保持原头不动,由调用方按未签名继续。
|
|
140
|
+
*/
|
|
141
|
+
async decorate(headers, scope) {
|
|
142
|
+
try {
|
|
143
|
+
const credential = parseClientSigningCredential(scope.apiKey);
|
|
144
|
+
if (!credential || !scope.clientVersion || !scope.sessionId)
|
|
145
|
+
return false;
|
|
146
|
+
const origin = new URL(scope.baseUrl).origin;
|
|
147
|
+
const cacheKey = `${scope.apiKey}\n${origin}`;
|
|
148
|
+
if ((this.retryAfter.get(cacheKey) ?? 0) > this.now())
|
|
149
|
+
return false;
|
|
150
|
+
const privateKey = await (this.keys.get(cacheKey)
|
|
151
|
+
?? this.beginHandshake(cacheKey, { ...credential, apiKey: scope.apiKey }, origin));
|
|
152
|
+
const ts = String(this.now());
|
|
153
|
+
const nonce = this.randomHexImpl(NONCE_BYTES);
|
|
154
|
+
const pow = solveClientRequestPow({ apiKeyId: credential.apiKeyId, sessionId: scope.sessionId, ts });
|
|
155
|
+
const message = `${credential.apiKeyId}\n${ts}\n${scope.clientVersion}\n${scope.sessionId}\n${nonce}`;
|
|
156
|
+
const signature = bytesToBase64(new Uint8Array(await crypto.subtle.sign("Ed25519", privateKey, encodeUtf8(message))));
|
|
157
|
+
headers.set("X-Client-Ts", ts);
|
|
158
|
+
headers.set("X-Client-Version", scope.clientVersion);
|
|
159
|
+
headers.set("X-Client-Sig", signature);
|
|
160
|
+
headers.set("X-Session-Id", scope.sessionId);
|
|
161
|
+
headers.set("X-Client-Nonce", nonce);
|
|
162
|
+
headers.set("X-App-Id", APP_ID);
|
|
163
|
+
headers.set("X-Client-Pow", pow);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** 服务端拒绝(VERIFY_*)或密钥疑似轮换后作废缓存;下一次 decorate 重新握手。 */
|
|
171
|
+
invalidate(apiKey, baseUrl) {
|
|
172
|
+
try {
|
|
173
|
+
const cacheKey = `${apiKey}\n${new URL(baseUrl).origin}`;
|
|
174
|
+
this.keys.delete(cacheKey);
|
|
175
|
+
this.retryAfter.delete(cacheKey);
|
|
176
|
+
}
|
|
177
|
+
catch { /* 无效 baseUrl 视为无可作废的缓存。 */ }
|
|
178
|
+
}
|
|
179
|
+
beginHandshake(cacheKey, credential, origin) {
|
|
180
|
+
const promise = this.handshake(credential, origin).catch((error) => {
|
|
181
|
+
this.keys.delete(cacheKey);
|
|
182
|
+
// 失败不缓存结果,但短冷却避免上游故障时每个模型请求都打一次握手。
|
|
183
|
+
this.retryAfter.set(cacheKey, this.now() + this.failureCooldownMs);
|
|
184
|
+
throw error;
|
|
185
|
+
});
|
|
186
|
+
this.keys.set(cacheKey, promise);
|
|
187
|
+
while (this.keys.size > MAX_KEY_ENTRIES)
|
|
188
|
+
this.keys.delete(this.keys.keys().next().value);
|
|
189
|
+
return promise;
|
|
190
|
+
}
|
|
191
|
+
async handshake(credential, origin) {
|
|
192
|
+
const ts = String(this.now());
|
|
193
|
+
const nonce = this.randomHexImpl(NONCE_BYTES);
|
|
194
|
+
const hmacKey = await crypto.subtle.importKey("raw", await deriveBytes(credential.apiKeySecret, HMAC_INFO), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
195
|
+
const sig = bytesToBase64(new Uint8Array(await crypto.subtle.sign("HMAC", hmacKey, encodeUtf8(`${HANDSHAKE_ACTION}\n${credential.apiKeyId}\n${ts}\n${nonce}`))));
|
|
196
|
+
const response = await this.fetchImpl(`${origin}${HANDSHAKE_PATH}`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { Authorization: credential.apiKey, "Content-Type": "application/json" },
|
|
199
|
+
body: JSON.stringify({ apiKey: credential.apiKey, nonce, sig, ts }),
|
|
200
|
+
redirect: "manual",
|
|
201
|
+
signal: AbortSignal.timeout(this.handshakeTimeoutMs),
|
|
202
|
+
});
|
|
203
|
+
const envelope = await response.json();
|
|
204
|
+
if (!response.ok || !envelope || typeof envelope !== "object")
|
|
205
|
+
throw new Error("client signing handshake protocol error");
|
|
206
|
+
if (envelope.code === 500)
|
|
207
|
+
throw new Error("client signing handshake server error");
|
|
208
|
+
if (envelope.code !== 200)
|
|
209
|
+
throw new Error(`client signing handshake rejected: ${String(envelope.msg ?? "")}`);
|
|
210
|
+
const cipher = typeof envelope.data?.privateCipher === "string" ? envelope.data.privateCipher : "";
|
|
211
|
+
if (!cipher)
|
|
212
|
+
throw new Error("client signing handshake omitted privateCipher");
|
|
213
|
+
return decryptSigningPrivateKey(credential, cipher);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** privateCipher = base64([12 字节 IV | AES-GCM(密文+tag)]);明文是 pkcs8 的 base64 字符串。 */
|
|
217
|
+
async function decryptSigningPrivateKey(credential, privateCipher) {
|
|
218
|
+
const cipher = base64ToBytes(privateCipher);
|
|
219
|
+
if (cipher.byteLength <= 12 + 16)
|
|
220
|
+
throw new Error("privateCipher is too short");
|
|
221
|
+
const aesKey = await crypto.subtle.importKey("raw", await deriveBytes(credential.apiKeySecret, PRIVATE_KEY_INFO), "AES-GCM", false, ["decrypt"]);
|
|
222
|
+
const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: cipher.slice(0, 12), additionalData: encodeUtf8(credential.apiKeyId), tagLength: 128 }, aesKey, cipher.slice(12)));
|
|
223
|
+
const pkcs8 = base64ToBytes(new TextDecoder().decode(plaintext));
|
|
224
|
+
return crypto.subtle.importKey("pkcs8", pkcs8, "Ed25519", false, ["sign"]);
|
|
225
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export type ZcodeFamily = "zai" | "bigmodel";
|
|
3
|
+
export interface ZcodeSelection {
|
|
4
|
+
family: ZcodeFamily;
|
|
5
|
+
providerID: string;
|
|
6
|
+
kind: "individual-coding-plan" | "team-coding-plan" | "start-plan" | "api-key";
|
|
7
|
+
team?: {
|
|
8
|
+
productId: string;
|
|
9
|
+
organizationId: string;
|
|
10
|
+
projectId: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface ZcodeProviderSnapshot {
|
|
14
|
+
family: ZcodeFamily;
|
|
15
|
+
providerID: string;
|
|
16
|
+
/** provider_config 的显示名;仅用于目录展示,不参与鉴权。 */
|
|
17
|
+
providerName?: string;
|
|
18
|
+
/** 该快照所属的套餐连接形态;决定对外 slug 作用域、显示名与鉴权头差异。 */
|
|
19
|
+
plan: ZcodeSelection["kind"];
|
|
20
|
+
apiKey: string;
|
|
21
|
+
baseURL: string;
|
|
22
|
+
modelIds: readonly string[];
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ZcodeConfigCache {
|
|
26
|
+
get(): Promise<ZcodeProviderSnapshot>;
|
|
27
|
+
/** 显式刷新本地配置与所需凭据;注入缓存可省略。 */
|
|
28
|
+
refresh?(): Promise<ZcodeProviderSnapshot>;
|
|
29
|
+
/** 仅重读本地目录元数据,不发起凭据网络;apiKey 可能为空,不用于请求认证。 */
|
|
30
|
+
reload?(): Promise<ZcodeProviderSnapshot>;
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare class ZcodeConfigError extends Error {
|
|
34
|
+
}
|
|
35
|
+
export interface ZcodeCacheDependencies {
|
|
36
|
+
/** 共享目录采集器可禁止启动首读,随后显式 refresh。 */
|
|
37
|
+
refreshOnStart?: boolean;
|
|
38
|
+
watch?: typeof fs.watch;
|
|
39
|
+
stat?: typeof fs.statSync;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
/** 固定解析某个套餐槽位(个人/团队/Start Plan/api-key),缺省跟随当前选择。 */
|
|
44
|
+
plan?: ZcodeSelection["kind"];
|
|
45
|
+
/** 固定 API Key provider;plan 为 api-key 时用于区分多个官方 Key。 */
|
|
46
|
+
apiProviderID?: string;
|
|
47
|
+
/** 只统计实际业务 Key 的构建,不接收或暴露密钥。 */
|
|
48
|
+
onCredentialBuild?: () => void;
|
|
49
|
+
/** 用于验证差分检查只在需要时读取 provider 配置。 */
|
|
50
|
+
onConfigRead?: () => void;
|
|
51
|
+
/** 套餐选择变化或当前选择不可解析时通知调用方,用于立即撤下已失效的下游目录条目。 */
|
|
52
|
+
onSelectionChange?: () => void;
|
|
53
|
+
/** 内存快照(渠道/套餐/模型集合/凭据)变化后触发,用于重建对外目录。 */
|
|
54
|
+
onSnapshotChange?: (snapshot: ZcodeProviderSnapshot) => void;
|
|
55
|
+
}
|
|
56
|
+
export interface ZcodePlanSelections {
|
|
57
|
+
/** 当前渠道(providerFamilyDomain);失败根因优先取它的解析错误。 */
|
|
58
|
+
domain: ZcodeFamily;
|
|
59
|
+
/** 各套餐槽位当前可解析的选择;同一槽位两个渠道都有时取当前渠道。 */
|
|
60
|
+
plans: Partial<Record<ZcodeSelection["kind"], ZcodeSelection>>;
|
|
61
|
+
/** 渠道级解析失败(未知 kind、缺 provider 选择等);该渠道的所有槽位都不可用。 */
|
|
62
|
+
failures: Partial<Record<ZcodeFamily, ZcodeConfigError>>;
|
|
63
|
+
}
|
|
64
|
+
export interface ZcodeAPIKeyProvider {
|
|
65
|
+
family: ZcodeFamily;
|
|
66
|
+
providerID: string;
|
|
67
|
+
providerName?: string;
|
|
68
|
+
}
|
|
69
|
+
/** 列出 provider_config 中全部可用官方 API Key,供目录为每个 Key 建独立路由。 */
|
|
70
|
+
export declare function readZcodeAPIKeyProviders(homeDirectory: string): ZcodeAPIKeyProvider[];
|
|
71
|
+
/**
|
|
72
|
+
* 本机 ZCode 配置是否就绪:setting.json 与 config.json/provider_config.json 至少
|
|
73
|
+
* 一组可用路由都存在才算可用。只做存在性探测,不读文件内容;home 与 v2 两种布局
|
|
74
|
+
* 各自按 readPreferred 的回退顺序判定。
|
|
75
|
+
*/
|
|
76
|
+
export declare function zcodeConfigPresent(homeDirectory: string): boolean;
|
|
77
|
+
/** 文件检查同步完成,凭证仅按 Key 的实际变化构建;事件不废弃有效快照。 */
|
|
78
|
+
export declare function createZcodeConfigCache(homeDirectory: string, dependencies?: ZcodeCacheDependencies): ZcodeConfigCache;
|