@wwkit/llmproxy 1.0.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/bin/index.js +119 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +25 -0
- package/src/config.js +51 -0
- package/src/config.json5 +51 -0
- package/src/core/auth.js +35 -0
- package/src/core/error.js +39 -0
- package/src/core/factory.js +102 -0
- package/src/core/retry.js +114 -0
- package/src/core/route.js +59 -0
- package/src/core/sign.js +32 -0
- package/src/core/transport.js +117 -0
- package/src/ctl-impl.js +55 -0
- package/src/ctl.js +311 -0
- package/src/index.js +4 -0
- package/src/providers/codearts/auth.js +325 -0
- package/src/providers/codearts/client.js +64 -0
- package/src/providers/codearts/config.js +8 -0
- package/src/providers/codearts/error.js +17 -0
- package/src/providers/codearts/sign.js +75 -0
- package/src/server.js +250 -0
- package/src/set.js +154 -0
- package/src/util.js +47 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// providers/codearts/auth.js — CodeArts OAuth 2.0 PKCE + DPoP 鉴权
|
|
2
|
+
//
|
|
3
|
+
// 流程:
|
|
4
|
+
// 1. 生成 PKCE pair + DPoP ES256 密钥对
|
|
5
|
+
// 2. 启动本地 HTTP 回调服务器
|
|
6
|
+
// 3. 用系统浏览器打开登录页(手动完成)
|
|
7
|
+
// 4. 浏览器重定向到回调,拿到 authorization code
|
|
8
|
+
// 5. POST sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens 换 AK/SK + securityToken + refresh_token
|
|
9
|
+
// 6. 后续用 refresh_token 自动续期
|
|
10
|
+
//
|
|
11
|
+
// 凭证存储:~/.config/llmproxy/.creds.json 的 providers.codearts 子树。
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto"
|
|
14
|
+
import http from "node:http"
|
|
15
|
+
import fs from "node:fs"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import process from "node:process"
|
|
18
|
+
import { getXdgConfigDir } from "@wwkit/shared"
|
|
19
|
+
import { status as cftStatus } from "@wwkit/cft"
|
|
20
|
+
import { AuthProvider } from "../../core/auth.js"
|
|
21
|
+
import { log } from "../../util.js"
|
|
22
|
+
|
|
23
|
+
const CREDS_FILE = path.join(getXdgConfigDir("llmproxy"), ".creds.json")
|
|
24
|
+
|
|
25
|
+
// 获取 Chrome 路径(优先用 @wwkit/cft 安装的 Chrome for Testing)
|
|
26
|
+
function getChromePath() {
|
|
27
|
+
try {
|
|
28
|
+
const info = cftStatus()
|
|
29
|
+
if (info?.chrome_path && fs.existsSync(info.chrome_path)) {
|
|
30
|
+
return info.chrome_path
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STS_HOST = "https://sts.cn-north-4.myhuaweicloud.com";
|
|
37
|
+
const TOKEN_API = "/v1/oauth2/tokens";
|
|
38
|
+
const PORTAL_HOST = "https://codearts.huaweicloud.com/portal";
|
|
39
|
+
const IAM_LOGIN = "https://auth.huaweicloud.com/authui/login.html";
|
|
40
|
+
const CLIENT_ID = "codearts-agent";
|
|
41
|
+
const PLUGIN_NAME = "snap_AIIDE";
|
|
42
|
+
const PLUGIN_VERSION = "5.3.0";
|
|
43
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
export default class CodeartsAuth extends AuthProvider {
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
super(opts);
|
|
48
|
+
this.account = opts.config.account || "";
|
|
49
|
+
this.password = opts.config.password || "";
|
|
50
|
+
this._cred = null;
|
|
51
|
+
this._loginContext = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getCredentials(opts = {}) {
|
|
55
|
+
const autoLogin = opts.autoLogin !== false;
|
|
56
|
+
if (this._cred && Date.now() < this._cred.expiresAt - REFRESH_MARGIN_MS) {
|
|
57
|
+
return this._cred;
|
|
58
|
+
}
|
|
59
|
+
if (!this._cred) await this._loadFromFile();
|
|
60
|
+
if (this._cred && this._loginContext) {
|
|
61
|
+
try {
|
|
62
|
+
await this._refresh();
|
|
63
|
+
return this._cred;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log(`[auth:${this.id}] refresh 失败: ${e.message},${autoLogin ? "重新登录" : "需重新 pnpm run login"}`);
|
|
66
|
+
if (!autoLogin) throw new Error(`auth:${this.id} refresh 失败: ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!autoLogin) {
|
|
70
|
+
throw new Error(`auth:${this.id} 无凭证,请先执行 pnpm run login --provider ${this.id}`);
|
|
71
|
+
}
|
|
72
|
+
await this.login({ manual: true });
|
|
73
|
+
return this._cred;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async login(opts = {}) {
|
|
77
|
+
log(`[auth:${this.id}] 开始浏览器登录...`);
|
|
78
|
+
|
|
79
|
+
const pkcePair = this._generatePKCE();
|
|
80
|
+
const dpopKeyPair = await this._generateDPoPKeyPair();
|
|
81
|
+
this._loginContext = { pkcePair, dpopKeyPair };
|
|
82
|
+
|
|
83
|
+
const { port, codePromise, server } = await this._startCallbackServer();
|
|
84
|
+
|
|
85
|
+
const authorizeUrl = this._buildAuthorizeUrl(port, pkcePair);
|
|
86
|
+
const loginUrl = `${IAM_LOGIN}?service=${encodeURIComponent(authorizeUrl)}`;
|
|
87
|
+
log(`[auth:${this.id}] 登录 URL: ${loginUrl.slice(0, 100)}...`);
|
|
88
|
+
|
|
89
|
+
if (opts.manual !== false) {
|
|
90
|
+
const { execSync } = await import("node:child_process");
|
|
91
|
+
const chromePath = getChromePath();
|
|
92
|
+
if (chromePath) {
|
|
93
|
+
try { execSync(`"${chromePath}" "${loginUrl}"`); } catch {}
|
|
94
|
+
log(`[auth:${this.id}] 已打开 Chrome,请手动完成登录...`);
|
|
95
|
+
} else {
|
|
96
|
+
const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
97
|
+
try { execSync(`${openCmd} "${loginUrl}"`) } catch {}
|
|
98
|
+
log(`[auth:${this.id}] 已打开浏览器,请手动完成登录...`)
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error(`auth:${this.id} 当前仅支持手动登录`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log(`[auth:${this.id}] 等待登录回调...`);
|
|
105
|
+
const code = await codePromise;
|
|
106
|
+
server.close();
|
|
107
|
+
log(`[auth:${this.id}] 收到 authorization code: ${code.slice(0, 12)}...`);
|
|
108
|
+
|
|
109
|
+
await this._exchangeCode(code, pkcePair, dpopKeyPair, port);
|
|
110
|
+
await this._saveToFile();
|
|
111
|
+
log(`[auth:${this.id}] 登录成功! AK=${mask(this._cred.accessKeyId)} expires=${new Date(this._cred.expiresAt).toISOString()}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_generatePKCE() {
|
|
115
|
+
const codeVerifier = crypto.randomBytes(64).toString("hex");
|
|
116
|
+
const sha256 = crypto.createHash("sha256").update(codeVerifier).digest("base64");
|
|
117
|
+
const codeChallenge = sha256.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
118
|
+
return { codeVerifier, codeChallenge, codeChallengeMethod: "SHA-256" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _generateDPoPKeyPair() {
|
|
122
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
123
|
+
const pubJwk = publicKey.export({ format: "jwk" });
|
|
124
|
+
const privJwk = privateKey.export({ format: "jwk" });
|
|
125
|
+
return { privateKeyJwk: privJwk, publicKeyJwk: pubJwk, privateKey, publicKey };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_signDPoP(dpopKeyPair, method, url) {
|
|
129
|
+
const payload = {
|
|
130
|
+
htm: method,
|
|
131
|
+
htu: url,
|
|
132
|
+
iat: Math.floor(Date.now() / 1000),
|
|
133
|
+
jti: crypto.randomBytes(32).toString("hex"),
|
|
134
|
+
};
|
|
135
|
+
const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKeyPair.publicKeyJwk };
|
|
136
|
+
const headerB64 = base64url(JSON.stringify(header));
|
|
137
|
+
const payloadB64 = base64url(JSON.stringify(payload));
|
|
138
|
+
const data = `${headerB64}.${payloadB64}`;
|
|
139
|
+
const sign = crypto.createSign("SHA256");
|
|
140
|
+
sign.update(data);
|
|
141
|
+
const derSig = sign.sign(dpopKeyPair.privateKey);
|
|
142
|
+
const rawSig = derToRawRS(derSig);
|
|
143
|
+
return `${data}.${base64urlBuf(rawSig)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_startCallbackServer() {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
let codeResolve;
|
|
149
|
+
const codePromise = new Promise((r) => { codeResolve = r; });
|
|
150
|
+
const server = http.createServer((req, res) => {
|
|
151
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
152
|
+
if (url.pathname === "/oauth/callback") {
|
|
153
|
+
const code = url.searchParams.get("code");
|
|
154
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
155
|
+
res.end("<html><body><h1>登录成功,可关闭此页面</h1></body></html>");
|
|
156
|
+
if (code) codeResolve(code);
|
|
157
|
+
} else {
|
|
158
|
+
res.writeHead(404);
|
|
159
|
+
res.end();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
server.listen(0, "127.0.0.1", () => {
|
|
163
|
+
const port = server.address().port;
|
|
164
|
+
log(`[auth:${this.id}] callback server listening on 127.0.0.1:${port}`);
|
|
165
|
+
resolve({ port, codePromise, server });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_buildAuthorizeUrl(port, pkcePair) {
|
|
171
|
+
const ticketId = crypto.randomBytes(16).toString("hex");
|
|
172
|
+
const params = new URLSearchParams({
|
|
173
|
+
theme: "Dark",
|
|
174
|
+
locale: "zh-cn",
|
|
175
|
+
uri_scheme: CLIENT_ID,
|
|
176
|
+
client_id: CLIENT_ID,
|
|
177
|
+
port: String(port),
|
|
178
|
+
code_challenge: pkcePair.codeChallenge,
|
|
179
|
+
code_challenge_method: pkcePair.codeChallengeMethod,
|
|
180
|
+
ticket_id: ticketId,
|
|
181
|
+
"plugin-name": PLUGIN_NAME,
|
|
182
|
+
"plugin-version": PLUGIN_VERSION,
|
|
183
|
+
});
|
|
184
|
+
return `${PORTAL_HOST}/authorize?${params.toString()}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _exchangeCode(code, pkcePair, dpopKeyPair, port) {
|
|
188
|
+
const body = new URLSearchParams({
|
|
189
|
+
client_id: CLIENT_ID,
|
|
190
|
+
code,
|
|
191
|
+
code_verifier: pkcePair.codeVerifier,
|
|
192
|
+
grant_type: "authorization_code",
|
|
193
|
+
redirect_uri: `http://127.0.0.1:${port}/oauth/callback`,
|
|
194
|
+
});
|
|
195
|
+
await this._requestToken(body, dpopKeyPair);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async _refresh() {
|
|
199
|
+
if (!this._cred?.refreshToken || !this._loginContext) {
|
|
200
|
+
throw new Error("无 refresh_token 或 loginContext");
|
|
201
|
+
}
|
|
202
|
+
const body = new URLSearchParams({
|
|
203
|
+
client_id: CLIENT_ID,
|
|
204
|
+
code_verifier: this._loginContext.pkcePair.codeVerifier,
|
|
205
|
+
grant_type: "refresh_token",
|
|
206
|
+
refresh_token: this._cred.refreshToken,
|
|
207
|
+
});
|
|
208
|
+
await this._requestToken(body, this._loginContext.dpopKeyPair);
|
|
209
|
+
await this._saveToFile();
|
|
210
|
+
log(`[auth:${this.id}] 续期成功 AK=${mask(this._cred.accessKeyId)} expires=${new Date(this._cred.expiresAt).toISOString()}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _requestToken(body, dpopKeyPair) {
|
|
214
|
+
const url = `${STS_HOST}${TOKEN_API}`;
|
|
215
|
+
const dpop = this._signDPoP(dpopKeyPair, "POST", url);
|
|
216
|
+
log(`[auth:${this.id}] 请求 STS token: ${url} (DPoP 头长度=${dpop.length})`);
|
|
217
|
+
const res = await fetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
221
|
+
"DPoP": dpop,
|
|
222
|
+
},
|
|
223
|
+
body: body.toString(),
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
const text = await res.text();
|
|
227
|
+
throw new Error(`auth:${this.id} STS token 请求失败 ${res.status}: ${text}`);
|
|
228
|
+
}
|
|
229
|
+
const data = await res.json();
|
|
230
|
+
const c = data.credentials || {};
|
|
231
|
+
this._cred = {
|
|
232
|
+
accessKeyId: c.access_key_id,
|
|
233
|
+
secretAccessKey: c.secret_access_key,
|
|
234
|
+
securityToken: c.security_token,
|
|
235
|
+
expiresAt: c.expiration ? Date.parse(c.expiration) : Date.now() + 3600_000,
|
|
236
|
+
refreshToken: data.refresh_token || this._cred?.refreshToken || "",
|
|
237
|
+
domainId: data.domain_id || this._cred?.domainId || "",
|
|
238
|
+
userId: data.user_id || "",
|
|
239
|
+
userName: data.user_name || "",
|
|
240
|
+
};
|
|
241
|
+
if (!this._cred.accessKeyId || !this._cred.secretAccessKey) {
|
|
242
|
+
throw new Error(`auth:${this.id} token 响应缺少 access_key_id/secret_access_key`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async _saveToFile() {
|
|
247
|
+
let all = {};
|
|
248
|
+
if (fs.existsSync(CREDS_FILE)) {
|
|
249
|
+
try { all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8")); } catch {}
|
|
250
|
+
}
|
|
251
|
+
if (!all.providers) all.providers = {};
|
|
252
|
+
all.providers[this.id] = {
|
|
253
|
+
accessKeyId: this._cred.accessKeyId,
|
|
254
|
+
secretAccessKey: this._cred.secretAccessKey,
|
|
255
|
+
securityToken: this._cred.securityToken,
|
|
256
|
+
expiresAt: this._cred.expiresAt,
|
|
257
|
+
refreshToken: this._cred.refreshToken,
|
|
258
|
+
domainId: this._cred.domainId,
|
|
259
|
+
loginContext: this._loginContext,
|
|
260
|
+
};
|
|
261
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async _loadFromFile() {
|
|
265
|
+
if (!fs.existsSync(CREDS_FILE)) return;
|
|
266
|
+
try {
|
|
267
|
+
const all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"));
|
|
268
|
+
const data = all.providers?.[this.id];
|
|
269
|
+
if (!data) return;
|
|
270
|
+
this._cred = {
|
|
271
|
+
accessKeyId: data.accessKeyId,
|
|
272
|
+
secretAccessKey: data.secretAccessKey,
|
|
273
|
+
securityToken: data.securityToken,
|
|
274
|
+
expiresAt: data.expiresAt,
|
|
275
|
+
refreshToken: data.refreshToken,
|
|
276
|
+
domainId: data.domainId,
|
|
277
|
+
};
|
|
278
|
+
if (data.loginContext?.dpopKeyPair?.privateKeyJwk) {
|
|
279
|
+
const privateKey = crypto.createPrivateKey({ key: data.loginContext.dpopKeyPair.privateKeyJwk, format: "jwk" });
|
|
280
|
+
const publicKey = crypto.createPublicKey({ key: data.loginContext.dpopKeyPair.publicKeyJwk, format: "jwk" });
|
|
281
|
+
this._loginContext = {
|
|
282
|
+
pkcePair: data.loginContext.pkcePair,
|
|
283
|
+
dpopKeyPair: { ...data.loginContext.dpopKeyPair, privateKey, publicKey },
|
|
284
|
+
};
|
|
285
|
+
} else {
|
|
286
|
+
this._loginContext = data.loginContext || null;
|
|
287
|
+
}
|
|
288
|
+
log(`[auth:${this.id}] 从 .creds.json 加载凭证 AK=${mask(this._cred.accessKeyId)} expires=${new Date(this._cred.expiresAt).toISOString()}`);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
log(`[auth:${this.id}] 加载 .creds.json 失败: ${e.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function base64url(s) {
|
|
296
|
+
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
297
|
+
}
|
|
298
|
+
function base64urlBuf(buf) {
|
|
299
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
300
|
+
}
|
|
301
|
+
function derToRawRS(der) {
|
|
302
|
+
const buf = Buffer.from(der);
|
|
303
|
+
let idx = 0;
|
|
304
|
+
if (buf[idx++] !== 0x30) throw new Error("invalid DER: expected SEQUENCE");
|
|
305
|
+
idx++;
|
|
306
|
+
if (buf[idx++] !== 0x02) throw new Error("invalid DER: expected INTEGER for R");
|
|
307
|
+
const rLen = buf[idx++];
|
|
308
|
+
const r = buf.slice(idx, idx + rLen);
|
|
309
|
+
idx += rLen;
|
|
310
|
+
if (buf[idx++] !== 0x02) throw new Error("invalid DER: expected INTEGER for S");
|
|
311
|
+
const sLen = buf[idx++];
|
|
312
|
+
const s = buf.slice(idx, idx + sLen);
|
|
313
|
+
const rFixed = fixLen(r, 32);
|
|
314
|
+
const sFixed = fixLen(s, 32);
|
|
315
|
+
return Buffer.concat([rFixed, sFixed]);
|
|
316
|
+
}
|
|
317
|
+
function fixLen(buf, len) {
|
|
318
|
+
if (buf.length === len) return buf;
|
|
319
|
+
if (buf.length > len) return buf.slice(buf.length - len);
|
|
320
|
+
return Buffer.concat([Buffer.alloc(len - buf.length), buf]);
|
|
321
|
+
}
|
|
322
|
+
function mask(s) {
|
|
323
|
+
if (!s || s.length < 6) return "****";
|
|
324
|
+
return s.slice(0, 2) + "****" + s.slice(-3);
|
|
325
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// providers/codearts/client.js — CodeArts 推理客户端
|
|
2
|
+
//
|
|
3
|
+
// 封装:auth 拿凭证 + sign 签名 + fetch 发请求
|
|
4
|
+
// 上游 host / path 从 sign config 读
|
|
5
|
+
// undici Agent 关闭 keep-alive 避免上游会话池占用
|
|
6
|
+
|
|
7
|
+
import { Agent, fetch } from "undici";
|
|
8
|
+
|
|
9
|
+
const upstreamAgent = new Agent({
|
|
10
|
+
connections: 1,
|
|
11
|
+
keepAliveTimeout: 100,
|
|
12
|
+
keepAliveMaxTimeout: 100,
|
|
13
|
+
pipelining: 0,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export default class CodeartsClient {
|
|
17
|
+
constructor({ id, signProvider, authProvider, host, basePath }) {
|
|
18
|
+
this.id = id;
|
|
19
|
+
this.signProvider = signProvider;
|
|
20
|
+
this.authProvider = authProvider;
|
|
21
|
+
this.host = host;
|
|
22
|
+
this.basePath = basePath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POST /chat/completions
|
|
27
|
+
* @param {object} body OpenAI 风格请求体
|
|
28
|
+
* @param {object} [opts] { headers?, signal? }
|
|
29
|
+
* @returns {Promise<Response>}
|
|
30
|
+
*/
|
|
31
|
+
async chatCompletions(body, opts = {}) {
|
|
32
|
+
const url = `https://${this.host}${this.basePath}/chat/completions`;
|
|
33
|
+
const bodyStr = JSON.stringify(body);
|
|
34
|
+
// chat 路径上不允许自动登录(避免阻塞请求路径)——凭证缺失直接抛错
|
|
35
|
+
const cred = await this.authProvider.getCredentials({ autoLogin: false });
|
|
36
|
+
|
|
37
|
+
const headers = {
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
"Host": this.host,
|
|
40
|
+
...opts.headers,
|
|
41
|
+
};
|
|
42
|
+
if (cred.securityToken) {
|
|
43
|
+
headers["X-Security-Token"] = cred.securityToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const signHeaders = this.signProvider.sign({
|
|
47
|
+
method: "POST",
|
|
48
|
+
url,
|
|
49
|
+
headers,
|
|
50
|
+
bodyStr,
|
|
51
|
+
accessKeyId: cred.accessKeyId,
|
|
52
|
+
secretAccessKey: cred.secretAccessKey,
|
|
53
|
+
});
|
|
54
|
+
Object.assign(headers, signHeaders);
|
|
55
|
+
|
|
56
|
+
return await fetch(url, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body: bodyStr,
|
|
60
|
+
signal: opts.signal || undefined,
|
|
61
|
+
dispatcher: upstreamAgent,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// providers/codearts/error.js — CodeArts / snap-access 错误模式识别
|
|
2
|
+
//
|
|
3
|
+
// sessionLimit: body 含 "TM.00001041" 即并发会话超限
|
|
4
|
+
|
|
5
|
+
import { ErrorPatterns } from "../../core/error.js";
|
|
6
|
+
|
|
7
|
+
// 错误模式配置(固定值,无需用户配置)
|
|
8
|
+
export const ERROR_CONFIG = {
|
|
9
|
+
sessionLimit: "TM.00001041",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default class CodeartsError extends ErrorPatterns {
|
|
13
|
+
isSessionLimit(_status, bodyText) {
|
|
14
|
+
const marker = this.config.sessionLimit || ERROR_CONFIG.sessionLimit;
|
|
15
|
+
return Boolean(bodyText && bodyText.includes(marker));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// providers/codearts/sign.js — 华为云 SDK-HMAC-SHA256 签名
|
|
2
|
+
//
|
|
3
|
+
// 对每个 chat 请求做签名(AK + body hash + canonical request),
|
|
4
|
+
// 返回 { Authorization, X-Sdk-Date } 头。
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { SignProvider } from "../../core/sign.js";
|
|
8
|
+
|
|
9
|
+
// 签名配置(固定值,无需用户配置)
|
|
10
|
+
export const SIGN_CONFIG = {
|
|
11
|
+
host: "snap-access.cn-north-4.myhuaweicloud.com",
|
|
12
|
+
basePath: "/api/v2",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default class CodeartsSign extends SignProvider {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} req { method, url, headers, bodyStr }
|
|
18
|
+
* @returns {object} { Authorization, X-Sdk-Date }
|
|
19
|
+
*/
|
|
20
|
+
sign(req) {
|
|
21
|
+
const u = new URL(req.url);
|
|
22
|
+
const sdkDate = formatSdkDate(new Date());
|
|
23
|
+
const bodyHash = crypto.createHash("sha256").update(req.bodyStr || "").digest("hex");
|
|
24
|
+
|
|
25
|
+
const reqHeaders = { ...req.headers };
|
|
26
|
+
reqHeaders["host"] = u.host;
|
|
27
|
+
reqHeaders["x-sdk-date"] = sdkDate;
|
|
28
|
+
|
|
29
|
+
const headerLower = {};
|
|
30
|
+
for (const [k, v] of Object.entries(reqHeaders)) {
|
|
31
|
+
headerLower[k.toLowerCase()] = String(v).trim();
|
|
32
|
+
}
|
|
33
|
+
headerLower["host"] = u.host;
|
|
34
|
+
headerLower["x-sdk-date"] = sdkDate;
|
|
35
|
+
|
|
36
|
+
const signedHeaderKeys = Object.keys(headerLower).sort();
|
|
37
|
+
const signedHeadersStr = signedHeaderKeys.join(";");
|
|
38
|
+
const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${headerLower[k]}\n`).join("");
|
|
39
|
+
const canonicalUri = u.pathname.endsWith("/") ? u.pathname : u.pathname + "/";
|
|
40
|
+
|
|
41
|
+
const queryParams = {};
|
|
42
|
+
u.searchParams.forEach((v, k) => { queryParams[k] = v; });
|
|
43
|
+
const canonicalQueryString = Object.keys(queryParams).sort()
|
|
44
|
+
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`)
|
|
45
|
+
.join("&");
|
|
46
|
+
|
|
47
|
+
const canonicalRequest = [
|
|
48
|
+
req.method.toUpperCase(),
|
|
49
|
+
canonicalUri,
|
|
50
|
+
canonicalQueryString,
|
|
51
|
+
canonicalHeaders,
|
|
52
|
+
signedHeadersStr,
|
|
53
|
+
bodyHash,
|
|
54
|
+
].join("\n");
|
|
55
|
+
|
|
56
|
+
const canonicalRequestHash = crypto.createHash("sha256").update(canonicalRequest).digest("hex");
|
|
57
|
+
const stringToSign = [
|
|
58
|
+
"SDK-HMAC-SHA256",
|
|
59
|
+
sdkDate,
|
|
60
|
+
canonicalRequestHash,
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
const secretAccessKey = req.secretAccessKey;
|
|
64
|
+
const accessKeyId = req.accessKeyId;
|
|
65
|
+
const signature = crypto.createHmac("sha256", secretAccessKey).update(stringToSign).digest("hex");
|
|
66
|
+
const authorization = `SDK-HMAC-SHA256 Access=${accessKeyId}, SignedHeaders=${signedHeadersStr}, Signature=${signature}`;
|
|
67
|
+
|
|
68
|
+
return { Authorization: authorization, "X-Sdk-Date": sdkDate };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatSdkDate(d) {
|
|
73
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
74
|
+
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
|
75
|
+
}
|