mslxdff 0.1.138 → 0.1.140
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/package.json +1 -1
- package/src/chooser.js +31 -12
- package/src/cli/commands/model/list-live.js +22 -0
- package/src/cli/commands/model/list.js +2 -0
- package/src/cli/commands/model/live-models.js +24 -0
- package/src/cli/commands/provider/codearts-login.js +45 -0
- package/src/cli/commands/provider/index.js +12 -0
- package/src/cli/commands/provider/keys.js +13 -2
- package/src/cli/commands/provider/models.js +9 -0
- package/src/cli/commands/provider/qoder-checkin.js +81 -0
- package/src/cli/commands/provider/qoder-login.js +103 -0
- package/src/cli/commands/provider/traework-login.js +202 -0
- package/src/cli/interactive.js +41 -4
- package/src/cli/provider-row.js +1 -1
- package/src/providers/classify.js +1 -1
- package/src/providers/codearts/auth-pool.js +148 -0
- package/src/providers/codearts/chat.js +176 -0
- package/src/providers/codearts/const.js +60 -0
- package/src/providers/codearts/dpop.js +95 -0
- package/src/providers/codearts/index.js +160 -0
- package/src/providers/codearts/login.js +214 -0
- package/src/providers/codearts/models.js +182 -0
- package/src/providers/codearts/sign.js +96 -0
- package/src/providers/codearts/sse.js +204 -0
- package/src/providers/codearts/stream.js +209 -0
- package/src/providers/codearts/sts.js +124 -0
- package/src/providers/codearts.js +1 -0
- package/src/providers/qoder/account-store.js +107 -0
- package/src/providers/qoder/aggregate.js +51 -0
- package/src/providers/qoder/baseprompt.json +592 -0
- package/src/providers/qoder/chat.js +72 -0
- package/src/providers/qoder/checkin.js +160 -0
- package/src/providers/qoder/constants.js +45 -0
- package/src/providers/qoder/encode.js +42 -0
- package/src/providers/qoder/fingerprint.js +39 -0
- package/src/providers/qoder/index.js +106 -0
- package/src/providers/qoder/models.js +62 -0
- package/src/providers/qoder/oauth.js +85 -0
- package/src/providers/qoder/payload.js +156 -0
- package/src/providers/qoder/request.js +26 -0
- package/src/providers/qoder/session.js +135 -0
- package/src/providers/qoder/sse.js +46 -0
- package/src/providers/qoder/stream.js +81 -0
- package/src/providers/qoder.js +2 -0
- package/src/providers/registry.js +15 -0
- package/src/providers/share-keys.js +2 -2
- package/src/providers/traework/account-store.js +149 -0
- package/src/providers/traework/chat.js +211 -0
- package/src/providers/traework/checkin.js +35 -0
- package/src/providers/traework/constants.js +21 -0
- package/src/providers/traework/errors.js +52 -0
- package/src/providers/traework/headers.js +47 -0
- package/src/providers/traework/index.js +122 -0
- package/src/providers/traework/models.js +97 -0
- package/src/providers/traework/payload.js +86 -0
- package/src/providers/traework/sse.js +184 -0
- package/src/providers/traework/token.js +52 -0
- package/src/providers/traework.js +2 -0
- package/src/routes/models-route.js +19 -1
- package/src/runtime/bootstrap.js +10 -0
- package/src/runtime/provider-gate.js +30 -0
- package/src/runtime/providers-setup.js +9 -3
- package/src/runtime/qoder-checkin.js +93 -0
- package/src/runtime/traework-checkin.js +104 -0
package/src/cli/interactive.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
// 交互式选择器:↑/↓
|
|
1
|
+
// 交互式选择器:↑/↓ 移动,←/→ 翻页,Enter 确认,q/Esc 取消;ANSI 原地重绘。
|
|
2
|
+
// 页高按终端行数自适应(items ≤ 页高时不分页,行为与旧版全量渲染一致)。
|
|
3
|
+
const PAGE_RESERVE = 6; // 页头 1 + 帮助 2 + 余量
|
|
4
|
+
|
|
5
|
+
function pageSizeFor(items) {
|
|
6
|
+
const rows = Number(process.stdout?.rows) || 24;
|
|
7
|
+
const size = Math.max(4, rows - PAGE_RESERVE);
|
|
8
|
+
return items.length > size ? size : items.length || 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
2
11
|
export async function pickInteractive(items, startCursor = 0) {
|
|
3
12
|
const { renderChooser, renderChooserHelp, parseKey } = await import("../chooser.js");
|
|
13
|
+
const size = pageSizeFor(items);
|
|
14
|
+
let page = 0;
|
|
4
15
|
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
5
16
|
const draw = () => {
|
|
6
|
-
const
|
|
17
|
+
const usePage = size < items.length;
|
|
18
|
+
const lines = [...renderChooser(items, cursor, usePage ? { index: page, size } : {}), ...renderChooserHelp(false, usePage)];
|
|
7
19
|
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
8
20
|
};
|
|
9
21
|
draw();
|
|
@@ -19,11 +31,22 @@ export async function pickInteractive(items, startCursor = 0) {
|
|
|
19
31
|
};
|
|
20
32
|
const onData = (chunk) => {
|
|
21
33
|
const key = parseKey(String(chunk));
|
|
34
|
+
const pages = Math.max(1, Math.ceil(items.length / size));
|
|
22
35
|
if (key === "up") {
|
|
23
36
|
cursor = (cursor - 1 + items.length) % items.length;
|
|
37
|
+
page = Math.floor(cursor / size);
|
|
24
38
|
draw();
|
|
25
39
|
} else if (key === "down") {
|
|
26
40
|
cursor = (cursor + 1) % items.length;
|
|
41
|
+
page = Math.floor(cursor / size);
|
|
42
|
+
draw();
|
|
43
|
+
} else if (key === "pageup") {
|
|
44
|
+
page = (page - 1 + pages) % pages;
|
|
45
|
+
cursor = page * size;
|
|
46
|
+
draw();
|
|
47
|
+
} else if (key === "pagedown") {
|
|
48
|
+
page = (page + 1) % pages;
|
|
49
|
+
cursor = Math.min(page * size, items.length - 1);
|
|
27
50
|
draw();
|
|
28
51
|
} else if (key === "enter") {
|
|
29
52
|
cleanup();
|
|
@@ -37,14 +60,17 @@ export async function pickInteractive(items, startCursor = 0) {
|
|
|
37
60
|
});
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
// 多选勾选:↑/↓
|
|
63
|
+
// 多选勾选:↑/↓ 移动,←/→ 翻页,Space 勾选/取消,Enter 保存,q/Esc 取消(返回 Set 或 null)
|
|
41
64
|
export async function pickInteractiveMulti(items, initialPicked = new Set(), startCursor = 0) {
|
|
42
65
|
const { renderChooser, renderChooserHelp, parseKey } = await import("../chooser.js");
|
|
66
|
+
const size = pageSizeFor(items);
|
|
67
|
+
let page = 0;
|
|
43
68
|
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
44
69
|
const picked = new Set(items.filter((it) => initialPicked.has(it.id)).map((it) => it.id));
|
|
45
70
|
const draw = () => {
|
|
71
|
+
const usePage = size < items.length;
|
|
46
72
|
const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
|
|
47
|
-
const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
|
|
73
|
+
const lines = [...renderChooser(rows, cursor, { multi: true, ...(usePage ? { index: page, size } : {}) }), ...renderChooserHelp(true, usePage)];
|
|
48
74
|
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
49
75
|
};
|
|
50
76
|
draw();
|
|
@@ -60,11 +86,22 @@ export async function pickInteractiveMulti(items, initialPicked = new Set(), sta
|
|
|
60
86
|
};
|
|
61
87
|
const onData = (chunk) => {
|
|
62
88
|
const key = parseKey(String(chunk));
|
|
89
|
+
const pages = Math.max(1, Math.ceil(items.length / size));
|
|
63
90
|
if (key === "up") {
|
|
64
91
|
cursor = (cursor - 1 + items.length) % items.length;
|
|
92
|
+
page = Math.floor(cursor / size);
|
|
65
93
|
draw();
|
|
66
94
|
} else if (key === "down") {
|
|
67
95
|
cursor = (cursor + 1) % items.length;
|
|
96
|
+
page = Math.floor(cursor / size);
|
|
97
|
+
draw();
|
|
98
|
+
} else if (key === "pageup") {
|
|
99
|
+
page = (page - 1 + pages) % pages;
|
|
100
|
+
cursor = page * size;
|
|
101
|
+
draw();
|
|
102
|
+
} else if (key === "pagedown") {
|
|
103
|
+
page = (page + 1) % pages;
|
|
104
|
+
cursor = Math.min(page * size, items.length - 1);
|
|
68
105
|
draw();
|
|
69
106
|
} else if (key === "space") {
|
|
70
107
|
const id = items[cursor].id;
|
package/src/cli/provider-row.js
CHANGED
|
@@ -40,7 +40,7 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
40
40
|
let allowed = [];
|
|
41
41
|
let allowAny = false;
|
|
42
42
|
try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
43
|
-
try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
|
|
43
|
+
try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : gid === "traework" ? "https://trae-api-cn.mchost.guru" : gid === "qoder" ? "qoder://native" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
|
|
44
44
|
try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
45
45
|
try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
46
46
|
// openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// latency-compare:key/token 类,direct vs link+remote 比延迟
|
|
5
5
|
export function classifyProvider(id) {
|
|
6
6
|
const s = String(id || "").trim().toLowerCase();
|
|
7
|
-
if (s === "workbuddy" || s === "cline") return "local-only";
|
|
7
|
+
if (s === "workbuddy" || s === "cline" || s === "codearts" || s === "traework" || s === "qoder") return "local-only";
|
|
8
8
|
if (s === "opencode") return "quota-pool";
|
|
9
9
|
return "latency-compare";
|
|
10
10
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 账号池:keyring 的 key = 凭证 blob JSON 字符串(一华为账号一 blob,多账号=多 key 自动轮转)。
|
|
2
|
+
// 职责:blob 解析;STS 凭证临期(默认提前 30min)单飞刷新;轮转写回(saveFn,原位换 key);
|
|
3
|
+
// 死号(终态失效)标记。瞬时失败保留旧凭证继续用,绝不清已存凭证(对齐 cline isInvalidGrant 纪律)。
|
|
4
|
+
import { refreshStsCredential } from "./sts.js";
|
|
5
|
+
import { REFRESH_SKEW_MS_DEFAULT } from "./const.js";
|
|
6
|
+
|
|
7
|
+
/** 校验并解析 blob 字符串 → 账号运行时对象;非 blob(如 Bearer key)返回 null。 */
|
|
8
|
+
export function accountFromBlob(raw) {
|
|
9
|
+
if (typeof raw !== "string" || !raw.trim().startsWith("{")) return null;
|
|
10
|
+
let j;
|
|
11
|
+
try { j = JSON.parse(raw); } catch { return null; }
|
|
12
|
+
if (!j || typeof j !== "object" || !j.refreshToken || !j.codeVerifier || !j.dpopJwk) return null;
|
|
13
|
+
const expMs = j.expiration ? Date.parse(j.expiration) : NaN;
|
|
14
|
+
return {
|
|
15
|
+
blob: raw,
|
|
16
|
+
userId: String(j.userId || j.user_id || ""),
|
|
17
|
+
userName: String(j.userName || j.user_name || ""),
|
|
18
|
+
domainId: String(j.domainId || j.domain_id || ""),
|
|
19
|
+
refreshToken: String(j.refreshToken || ""),
|
|
20
|
+
clientId: String(j.clientId || "codearts-agent"),
|
|
21
|
+
codeVerifier: String(j.codeVerifier || ""),
|
|
22
|
+
dpopJwk: j.dpopJwk || null,
|
|
23
|
+
accessKeyId: String(j.accessKeyId || ""),
|
|
24
|
+
secretAccessKey: String(j.secretAccessKey || ""),
|
|
25
|
+
securityToken: String(j.securityToken || ""),
|
|
26
|
+
expiration: String(j.expiration || ""),
|
|
27
|
+
expiresAtMs: Number.isFinite(expMs) ? expMs : 0,
|
|
28
|
+
dead: false,
|
|
29
|
+
lastError: "",
|
|
30
|
+
refreshInflight: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 账号对象 → 可持久化 blob(JSON 字符串;AK/SK/securityToken 是 STS 临时凭证快照)。 */
|
|
35
|
+
export function blobFromAccount(acc) {
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
userId: acc.userId,
|
|
38
|
+
userName: acc.userName,
|
|
39
|
+
domainId: acc.domainId,
|
|
40
|
+
refreshToken: acc.refreshToken,
|
|
41
|
+
clientId: acc.clientId,
|
|
42
|
+
codeVerifier: acc.codeVerifier,
|
|
43
|
+
dpopJwk: acc.dpopJwk,
|
|
44
|
+
accessKeyId: acc.accessKeyId,
|
|
45
|
+
secretAccessKey: acc.secretAccessKey,
|
|
46
|
+
securityToken: acc.securityToken,
|
|
47
|
+
expiration: acc.expiration,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createAuthPool({
|
|
52
|
+
keys = [],
|
|
53
|
+
fetchImpl,
|
|
54
|
+
stsHost,
|
|
55
|
+
skewMs = REFRESH_SKEW_MS_DEFAULT,
|
|
56
|
+
clock = () => Date.now(),
|
|
57
|
+
saveFn,
|
|
58
|
+
} = {}) {
|
|
59
|
+
const accounts = new Map(); // blob → account
|
|
60
|
+
for (const k of keys) {
|
|
61
|
+
const acc = accountFromBlob(k);
|
|
62
|
+
if (acc) accounts.set(k, acc);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findByBlob(blob) { return accounts.get(blob) || null; }
|
|
66
|
+
function aliveAccounts() { return [...accounts.values()].filter((a) => !a.dead); }
|
|
67
|
+
function pickPrimary() { return aliveAccounts()[0] || null; }
|
|
68
|
+
|
|
69
|
+
async function doRefresh(acc) {
|
|
70
|
+
const r = await refreshStsCredential(
|
|
71
|
+
{ refreshToken: acc.refreshToken, codeVerifier: acc.codeVerifier, dpopJwk: acc.dpopJwk, clientId: acc.clientId },
|
|
72
|
+
{ fetchImpl, stsHost },
|
|
73
|
+
);
|
|
74
|
+
if (r.relogin) {
|
|
75
|
+
acc.dead = true;
|
|
76
|
+
acc.lastError = r.error || "re-login required";
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (r.error) { acc.lastError = r.error; return; } // 瞬时失败:旧凭证还能用就继续用
|
|
80
|
+
// 成功:STS 字段更新(refreshToken 可能轮转),原位换 blob 写回
|
|
81
|
+
acc.accessKeyId = r.account.accessKeyId;
|
|
82
|
+
acc.secretAccessKey = r.account.secretAccessKey;
|
|
83
|
+
acc.securityToken = r.account.securityToken;
|
|
84
|
+
acc.expiration = r.account.expiration;
|
|
85
|
+
acc.expiresAtMs = r.account.expiresAtMs;
|
|
86
|
+
if (r.account.refreshToken) acc.refreshToken = r.account.refreshToken;
|
|
87
|
+
if (r.account.userId) acc.userId = r.account.userId;
|
|
88
|
+
if (r.account.userName) acc.userName = r.account.userName;
|
|
89
|
+
if (r.account.domainId) acc.domainId = r.account.domainId;
|
|
90
|
+
acc.lastError = "";
|
|
91
|
+
acc.dead = false;
|
|
92
|
+
const newBlob = blobFromAccount(acc);
|
|
93
|
+
if (newBlob !== acc.blob) {
|
|
94
|
+
const oldBlob = acc.blob;
|
|
95
|
+
accounts.delete(oldBlob);
|
|
96
|
+
acc.blob = newBlob;
|
|
97
|
+
accounts.set(newBlob, acc);
|
|
98
|
+
if (saveFn) { try { await saveFn({ oldBlob, newBlob, account: acc }); } catch {} }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 取某账号的有效 STS 凭证(临期自动单飞刷新)。
|
|
104
|
+
* @throws {Error} blob 不在池内 / 死号(message 带 re-login 提示)
|
|
105
|
+
*/
|
|
106
|
+
async function getCredential(blob) {
|
|
107
|
+
const acc = findByBlob(blob);
|
|
108
|
+
if (!acc) throw new Error("codearts: credential not in pool — restart daemon or re-login");
|
|
109
|
+
if (acc.dead) throw new Error(`codearts account ${acc.userId || "?"} dead: ${acc.lastError || "re-login required"} — run: mslxdff -provider codearts login`);
|
|
110
|
+
const now = clock();
|
|
111
|
+
const needRefresh = !acc.securityToken || !acc.accessKeyId
|
|
112
|
+
|| (acc.expiresAtMs > 0 && now >= acc.expiresAtMs - skewMs);
|
|
113
|
+
if (needRefresh && !acc.refreshInflight) {
|
|
114
|
+
acc.refreshInflight = doRefresh(acc).finally(() => { acc.refreshInflight = null; });
|
|
115
|
+
}
|
|
116
|
+
if (acc.refreshInflight) await acc.refreshInflight;
|
|
117
|
+
if (acc.dead) throw new Error(`codearts account ${acc.userId || "?"} dead: ${acc.lastError || "re-login required"} — run: mslxdff -provider codearts login`);
|
|
118
|
+
return acc;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 强制下次刷新(401 后调用;清掉缓存的 STS 临时凭证)。 */
|
|
122
|
+
function invalidate(blob) {
|
|
123
|
+
const acc = findByBlob(blob);
|
|
124
|
+
if (!acc) return;
|
|
125
|
+
acc.securityToken = "";
|
|
126
|
+
acc.accessKeyId = "";
|
|
127
|
+
acc.secretAccessKey = "";
|
|
128
|
+
acc.expiresAtMs = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function markDead(blob, reason) {
|
|
132
|
+
const acc = findByBlob(blob);
|
|
133
|
+
if (!acc) return;
|
|
134
|
+
acc.dead = true;
|
|
135
|
+
acc.lastError = reason || acc.lastError;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** ring 轮转写回后的重排(saveFn 成功后由 index 调用;saveFn 已处理则幂等)。 */
|
|
139
|
+
function replace(oldBlob, newBlob) {
|
|
140
|
+
const acc = accounts.get(oldBlob);
|
|
141
|
+
if (!acc || oldBlob === newBlob) return;
|
|
142
|
+
accounts.delete(oldBlob);
|
|
143
|
+
acc.blob = newBlob;
|
|
144
|
+
accounts.set(newBlob, acc);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { findByBlob, aliveAccounts, pickPrimary, getCredential, invalidate, markDead, replace, accounts: () => [...accounts.values()] };
|
|
148
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// CodeArts 聊天服务:上游 body/头组装 + SDK-HMAC-SHA256 签名 + 401 刷新重试 + 环形切号。
|
|
2
|
+
// 上游恒 stream:true(chatBodyV2 契约);流式客户端转 OpenAI SSE,非流式聚合回 JSON。
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { joinUrl } from "../base.js";
|
|
5
|
+
import { createTransport } from "../../transport/index.js";
|
|
6
|
+
import { SNAP_BASE, EP_CHAT_V2, chatBaseHeaders, MAAS_TYPE_HEADER, MAAS_BENEFIT } from "./const.js";
|
|
7
|
+
import { signRequest } from "./sign.js";
|
|
8
|
+
import { newChatId, preflightResponse, sseToOpenAIResponse, aggregateToCompletion, UpstreamEventError } from "./stream.js";
|
|
9
|
+
|
|
10
|
+
const MODEL_RULES = [
|
|
11
|
+
{ match: (m) => String(m || "").toLowerCase().includes("deepseek"), scope: "all" },
|
|
12
|
+
{ match: (m) => /(^kimi-|kimi)/i.test(String(m || "")), scope: "toolCalls" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// DeepSeek 系思考模式要求 assistant 消息回传 reasoning_content,缺失补 " " 占位(ADR-0001 同款)。
|
|
16
|
+
export function injectReasoningPlaceholder(messages, model) {
|
|
17
|
+
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
18
|
+
if (!rule || !Array.isArray(messages)) return messages;
|
|
19
|
+
return messages.map((m) => {
|
|
20
|
+
if (m?.role !== "assistant") return m;
|
|
21
|
+
if (typeof m.reasoning_content === "string" && m.reasoning_content.length) return m;
|
|
22
|
+
if (rule.scope === "toolCalls" && !(Array.isArray(m.tool_calls) && m.tool_calls.length)) return m;
|
|
23
|
+
return { ...m, reasoning_content: " " };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PASSTHROUGH_KEYS = ["temperature", "top_p", "max_tokens", "max_completion_tokens", "reasoning_effort", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"];
|
|
28
|
+
|
|
29
|
+
export function buildUpstreamBody(body, { chatId, upstreamModel }) {
|
|
30
|
+
const out = {
|
|
31
|
+
model: upstreamModel,
|
|
32
|
+
stream: true, // v2 端点契约:恒流式,非流式由本层聚合
|
|
33
|
+
messages: injectReasoningPlaceholder(body?.messages || [], upstreamModel),
|
|
34
|
+
prompt_cache_key: chatId,
|
|
35
|
+
tool_stream: true,
|
|
36
|
+
chat_id: chatId,
|
|
37
|
+
};
|
|
38
|
+
for (const k of PASSTHROUGH_KEYS) {
|
|
39
|
+
if (body?.[k] !== undefined && body?.[k] !== null) out[k] = body[k];
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Session-Id:由 chatId 派生的稳定会话标识(prompt cache 亲和,对齐 Go sessionIDFor)。
|
|
45
|
+
function sessionIdFor(chatId) {
|
|
46
|
+
return crypto.createHash("sha256").update(`codearts-session:${chatId}`, "utf8").digest("hex").slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripOwnPrefix(model, providerId) {
|
|
50
|
+
const s = String(model || "");
|
|
51
|
+
const i = s.indexOf("/");
|
|
52
|
+
if (i <= 0) return s;
|
|
53
|
+
return s.slice(0, i).toLowerCase() === String(providerId || "").toLowerCase() ? s.slice(i + 1) : s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createChatService({
|
|
57
|
+
id = "codearts",
|
|
58
|
+
baseUrl = SNAP_BASE,
|
|
59
|
+
chatPath = EP_CHAT_V2,
|
|
60
|
+
fetchImpl,
|
|
61
|
+
dispatcher,
|
|
62
|
+
authPool,
|
|
63
|
+
catalog,
|
|
64
|
+
connectTimeoutMs = 30_000,
|
|
65
|
+
clock = Date.now,
|
|
66
|
+
} = {}) {
|
|
67
|
+
const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "") || SNAP_BASE;
|
|
68
|
+
const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
|
|
69
|
+
|
|
70
|
+
async function sendOnce({ bodyStr, account, benefit, chatId, sessionId, traceId }) {
|
|
71
|
+
const url = joinUrl(resolvedBase, chatPath);
|
|
72
|
+
const h = chatBaseHeaders(account.securityToken, traceId);
|
|
73
|
+
if (benefit) h[MAAS_TYPE_HEADER] = MAAS_BENEFIT;
|
|
74
|
+
const { headers } = signRequest({ method: "POST", url, headers: h, body: bodyStr, cred: account });
|
|
75
|
+
// Chat-Id / Session-Id 在签名之后追加(对齐官方客户端:不进 SignedHeaders)。
|
|
76
|
+
headers["chat-id"] = chatId;
|
|
77
|
+
headers["session-id"] = sessionId;
|
|
78
|
+
return transport.request({ url, method: "POST", headers, body: bodyStr, stream: true, timeoutMs: connectTimeoutMs });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function errRes(status, msg, code) {
|
|
82
|
+
return new Response(JSON.stringify({ error: { message: msg, type: "upstream_error", ...(code ? { code } : {}) } }), {
|
|
83
|
+
status,
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function withTiming(res, t0) {
|
|
89
|
+
try { res._t = { attempts: [], waitMs: 0, totalMs: Math.round(clock() - t0) }; } catch {}
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 跑一次对话。body.model 已由 dispatcher 剥前缀(容忍自带前缀)。
|
|
95
|
+
* @returns {Promise<Response>} 流式 → OpenAI SSE;非流式 → chat.completion JSON
|
|
96
|
+
*/
|
|
97
|
+
async function runChat(body, activeRing, opts = {}) {
|
|
98
|
+
const t0 = clock();
|
|
99
|
+
const requestedModel = String(body?.model || "");
|
|
100
|
+
const rawModel = stripOwnPrefix(requestedModel, id);
|
|
101
|
+
const upstreamModel = catalog?.canonical(rawModel) || rawModel;
|
|
102
|
+
const chatId = newChatId(body, opts);
|
|
103
|
+
const sessionId = sessionIdFor(chatId);
|
|
104
|
+
const upstreamBody = buildUpstreamBody(body, { chatId, upstreamModel });
|
|
105
|
+
const bodyStr = JSON.stringify(upstreamBody);
|
|
106
|
+
const isStream = body?.stream === true;
|
|
107
|
+
let lastErr = null;
|
|
108
|
+
|
|
109
|
+
const maxTries = Math.max(1, activeRing.size || 1);
|
|
110
|
+
for (let attempt = 0; attempt < maxTries; attempt++) {
|
|
111
|
+
const blob = activeRing.next();
|
|
112
|
+
if (!blob) break;
|
|
113
|
+
let account;
|
|
114
|
+
try {
|
|
115
|
+
account = await authPool.getCredential(blob);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
try { activeRing.onError(blob); } catch {}
|
|
118
|
+
lastErr = err;
|
|
119
|
+
continue; // 死号/不在池:换下一个账号
|
|
120
|
+
}
|
|
121
|
+
const benefit = catalog?.isBenefit ? catalog.isBenefit(account.userId, upstreamModel) : false;
|
|
122
|
+
const traceId = crypto.randomBytes(8).toString("hex") + String(clock());
|
|
123
|
+
let res;
|
|
124
|
+
try {
|
|
125
|
+
res = await sendOnce({ bodyStr, account, benefit, chatId, sessionId, traceId });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
try { activeRing.onError(blob); } catch {}
|
|
128
|
+
lastErr = err;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (res.status === 401 || res.status === 403) {
|
|
132
|
+
// STS 临时凭证失效:强制刷新重试一次,仍 401/403 换号
|
|
133
|
+
authPool.invalidate(blob);
|
|
134
|
+
let account2 = null;
|
|
135
|
+
try { account2 = await authPool.getCredential(blob); } catch { /* 死号 → 换号 */ }
|
|
136
|
+
if (account2) {
|
|
137
|
+
const traceId2 = crypto.randomBytes(8).toString("hex") + String(clock());
|
|
138
|
+
try { res = await sendOnce({ bodyStr, account: account2, benefit, chatId, sessionId, traceId: traceId2 }); } catch (err2) { lastErr = err2; try { activeRing.onError(blob); } catch {} continue; }
|
|
139
|
+
if (res.status === 401 || res.status === 403) {
|
|
140
|
+
try { await res.text(); } catch {}
|
|
141
|
+
try { activeRing.onError(blob); } catch {}
|
|
142
|
+
lastErr = new Error(`codearts auth still failing after refresh (http ${res.status})`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
try { activeRing.onError(blob); } catch {}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (res.status >= 400) {
|
|
151
|
+
const txt = await res.text().catch(() => "");
|
|
152
|
+
if (res.status === 429 || res.status >= 500) try { activeRing.onError(blob); } catch {}
|
|
153
|
+
return withTiming(errRes(res.status, `codearts upstream http ${res.status}: ${String(txt).slice(0, 300)}`), t0);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const pre = await preflightResponse(res); // HTTP 200 内嵌错误(排队/未注册/福利未领)→ 抛 UpstreamEventError
|
|
157
|
+
if (isStream) return withTiming(sseToOpenAIResponse(pre, { model: requestedModel || upstreamModel, id: `chatcmpl-${chatId.slice(0, 12)}` }), t0);
|
|
158
|
+
const data = await aggregateToCompletion(pre, { model: requestedModel || upstreamModel, id: `chatcmpl-${chatId.slice(0, 12)}` });
|
|
159
|
+
return withTiming(new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" } }), t0);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err instanceof UpstreamEventError) {
|
|
162
|
+
if (err.status === 429 || err.status >= 500) try { activeRing.onError(blob); } catch {}
|
|
163
|
+
return withTiming(errRes(err.status, `codearts upstream: ${err.message}`, err.code), t0);
|
|
164
|
+
}
|
|
165
|
+
try { activeRing.onError(blob); } catch {}
|
|
166
|
+
lastErr = err;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw Object.assign(lastErr || new Error("codearts: all accounts unavailable"), {
|
|
171
|
+
_t: { attempts: [], waitMs: 0, totalMs: Math.round(clock() - t0) },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { runChat, buildUpstreamBody, injectReasoningPlaceholder, _sendOnce: sendOnce };
|
|
176
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// CodeArts Agent 云端 API 常量(逆向自 HITZY2002/codearts2api,源自 vscode-codebot 26.7.0)。
|
|
2
|
+
// 端点契约见 docs/adr/0027-codearts-provider.md。
|
|
3
|
+
|
|
4
|
+
// 盘古引擎/聊天网关(product.json snapEngineDomain,商业版 cn-north-4)。
|
|
5
|
+
export const SNAP_BASE = "https://snap-access.cn-north-4.myhuaweicloud.com";
|
|
6
|
+
// STS 令牌端点(refresh_token 与 client_id + DPoP 公钥 + code_verifier 三重绑定)。
|
|
7
|
+
export const STS_HOST = "https://sts.cn-north-4.myhuaweicloud.com";
|
|
8
|
+
// 限时福利(免费套餐)网关(模型发现 + 领取)。
|
|
9
|
+
export const BENEFIT_HOST = "https://opengw.developer.huaweicloud.com";
|
|
10
|
+
// OAuth authorize 入口(注意带 /portal)。
|
|
11
|
+
export const PORTAL_HOST = "https://codearts.huaweicloud.com/portal";
|
|
12
|
+
|
|
13
|
+
export const EP_CHAT_V2 = "/api/v2/chat/completions";
|
|
14
|
+
export const EP_MODEL_BUILTIN = "/v1/model/builtin";
|
|
15
|
+
export const EP_AGENT_LIST = "/v1/agent-center/agents/useragents";
|
|
16
|
+
export const EP_AGENT_DETAIL = "/v1/agent-center/agents/detail";
|
|
17
|
+
export const EP_BENEFIT_CONFIG = "/api/v1/gateway/config";
|
|
18
|
+
export const EP_BENEFIT_CLAIM = "/api/v1/benefit/claim";
|
|
19
|
+
export const EP_OAUTH_TOKENS = "/v1/oauth2/tokens";
|
|
20
|
+
export const EP_LOGIN_TICKET = "/v1/login/ticket";
|
|
21
|
+
export const SNAP_MANAGER_PATH = "/snap-manager";
|
|
22
|
+
|
|
23
|
+
// OAuth client:官方 CodeArts Agent 插件的 uri_scheme;refresh_token 与之绑定。
|
|
24
|
+
export const CLIENT_ID = "codearts-agent";
|
|
25
|
+
// ticket 轮询的插件标识(逆向自 huaweicloud.authentication 扩展)。
|
|
26
|
+
export const PLUGIN_NAME = "snap_AIIDE";
|
|
27
|
+
export const PLUGIN_VERSION = "5.2.0";
|
|
28
|
+
|
|
29
|
+
// 聊天路由头(官方 AgentKernel 形状;均进入 SDK-HMAC-SHA256 签名)。
|
|
30
|
+
export function chatBaseHeaders(securityToken, traceId) {
|
|
31
|
+
return {
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
"accept": "text/event-stream",
|
|
34
|
+
"x-auth-token": securityToken || "",
|
|
35
|
+
"x-snap-traceid": traceId,
|
|
36
|
+
"x-language": "zh-cn",
|
|
37
|
+
"app-id": "CodeAgent3.0",
|
|
38
|
+
"is_confidential": "false",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 福利路由头:必须在 signRequest 之前设置(计入 SignedHeaders),否则 InferHub.002002009.404。
|
|
43
|
+
export const MAAS_TYPE_HEADER = "maas_type";
|
|
44
|
+
export const MAAS_BENEFIT = "benefit";
|
|
45
|
+
|
|
46
|
+
// 冷启动种子:福利套餐轮换后新模型在首次发现前也按福利路由(宁多带不少带,少带即 404)。
|
|
47
|
+
export const SEED_BENEFIT_MODELS = ["deepseek-v4-flash-0731", "deepseek-v4-pro-0813", "glm-5.3-flash"];
|
|
48
|
+
// 内置模型种子(仅用于大小写归一,不标福利)。
|
|
49
|
+
export const SEED_KNOWN_MODELS = [
|
|
50
|
+
"GLM-5.2", "GLM-5.2-ArkTS-SPARK", "GLM-5.1", "GLM-4.7",
|
|
51
|
+
"OpenPangu-2.0-Pro", "OpenPangu-2.0-Flash",
|
|
52
|
+
"Qwen3-VL-235B", "Qwen3.5-397B-A17B-VL", "Qwen3.6-27B-VL",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// 目录 TTL:超过则建议重新发现(对齐 codearts2api CatalogTTL)。
|
|
56
|
+
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
57
|
+
// listModels 缓存(与全仓 10min 对齐)。
|
|
58
|
+
export const MODELS_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
59
|
+
// STS 凭证提前刷新量(对齐 codearts2api RefreshSkew 30min)。
|
|
60
|
+
export const REFRESH_SKEW_MS_DEFAULT = 30 * 60 * 1000;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// DPoP(RFC 9449)证明 JWT:ES256 + P-256,供 STS /v1/oauth2/tokens 请求头使用。
|
|
2
|
+
// refresh_token 与首次换取时的 DPoP 公钥绑定:私钥 JWK 必须随凭证持久化(d 字段),
|
|
3
|
+
// 换新私钥刷新会被 STS 拒(400 invalid refresh token: InvalidDPoPHeader)。
|
|
4
|
+
// 算法对齐 HITZY2002/codearts2api internal/upstream/dpop.go。
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// P-256 阶 n(用于低 S 归一化:s > n/2 → s = n - s,jose 默认低 S 兼容性更好)。
|
|
8
|
+
const P256_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
|
|
9
|
+
|
|
10
|
+
const b64url = (data) => Buffer.from(data).toString("base64url");
|
|
11
|
+
|
|
12
|
+
/** 生成可持久化的 DPoP 私钥 JWK(kty/crv/x/y/d,均 base64url 无 padding)。 */
|
|
13
|
+
export function newDpopPrivateJwk() {
|
|
14
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
15
|
+
const pub = publicKey.export({ format: "jwk" });
|
|
16
|
+
const priv = privateKey.export({ format: "jwk" });
|
|
17
|
+
return { kty: "EC", crv: "P-256", x: pub.x, y: pub.y, d: priv.d };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function dpopPublicJwk(privateJwk) {
|
|
21
|
+
return { kty: "EC", crv: "P-256", x: privateJwk?.x || "", y: privateJwk?.y || "" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isValidDpopJwk(jwk) {
|
|
25
|
+
return !!jwk && jwk.kty === "EC" && jwk.crv === "P-256" && typeof jwk.d === "string" && jwk.d.length > 0 && !!jwk.x && !!jwk.y;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 从持久化 JWK 恢复私钥对象(非法材料直接抛错,调用方须提示重新登录)。 */
|
|
29
|
+
export function dpopPrivateKey(jwk) {
|
|
30
|
+
if (!isValidDpopJwk(jwk)) throw new Error("codearts: invalid DPoP private JWK — re-login required");
|
|
31
|
+
try {
|
|
32
|
+
return crypto.createPrivateKey({ key: { ...jwk }, format: "jwk" });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new Error(`codearts: DPoP private JWK unusable (${err?.message || err}) — re-login required`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 低 S 归一化:ieee-p1363 64 字节 r||s 原地修正 s(合法:ECDSA (r, n-s) 等价)。 */
|
|
39
|
+
function normalizeLowS(sig) {
|
|
40
|
+
if (sig.length !== 64) return sig;
|
|
41
|
+
const s = BigInt("0x" + sig.subarray(32, 64).toString("hex"));
|
|
42
|
+
const half = P256_N >> 1n;
|
|
43
|
+
if (s <= half || s === 0n) return sig;
|
|
44
|
+
const fixed = P256_N - s;
|
|
45
|
+
Buffer.from(fixed.toString(16).padStart(64, "0"), "hex").copy(sig, 32);
|
|
46
|
+
return sig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 生成 DPoP proof JWT。
|
|
51
|
+
* @param {object} privateJwk 持久化私钥 JWK(含 d)
|
|
52
|
+
* @param {string} htu 目标端点完整 URL(STS token 端点)
|
|
53
|
+
* @returns {string} `b64(header).b64(payload).b64(sig)` 三段 JWT
|
|
54
|
+
*/
|
|
55
|
+
export function signDpopProof(privateJwk, htu) {
|
|
56
|
+
const key = dpopPrivateKey(privateJwk);
|
|
57
|
+
const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopPublicJwk(privateJwk) };
|
|
58
|
+
const payload = {
|
|
59
|
+
htm: "POST",
|
|
60
|
+
htu: String(htu || ""),
|
|
61
|
+
iat: Math.floor(Date.now() / 1000),
|
|
62
|
+
jti: crypto.randomBytes(32).toString("hex"),
|
|
63
|
+
};
|
|
64
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
65
|
+
// 标准 JWS/ES256:对 ASCII(input) 做 SHA256+ECDSA(crypto.sign("SHA256", input))。
|
|
66
|
+
// 注意:千万别用 sign(null, sha256(input))——OpenSSL 的 noneWithECDSA 把 digest 当整数标量,
|
|
67
|
+
// 签出来自验能过、但任何标准验签方(含 WebCrypto/华为 STS)都拒签(真机 400 STS5.1804 实锤)。
|
|
68
|
+
const sig = normalizeLowS(crypto.sign("SHA256", Buffer.from(input, "utf8"), { key, dsaEncoding: "ieee-p1363" }));
|
|
69
|
+
return `${input}.${b64url(sig)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 验签辅助(测试/诊断用):校验 JWT 签名是否与 JWK 公钥匹配。 */
|
|
73
|
+
export function verifyDpopProof(proof) {
|
|
74
|
+
const parts = String(proof || "").split(".");
|
|
75
|
+
if (parts.length !== 3) return { ok: false };
|
|
76
|
+
try {
|
|
77
|
+
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
78
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
79
|
+
const sig = Buffer.from(parts[2], "base64url");
|
|
80
|
+
const pub = crypto.createPublicKey({ key: { kty: "EC", crv: "P-256", x: header.jwk.x, y: header.jwk.y }, format: "jwk" });
|
|
81
|
+
const input = Buffer.from(`${parts[0]}.${parts[1]}`, "utf8");
|
|
82
|
+
const s = BigInt("0x" + sig.subarray(32, 64).toString("hex"));
|
|
83
|
+
// 标准 JWS/ES256 验签(与签名端对称;dsaEncoding 必须显式 ieee-p1363,node 默认 DER)。
|
|
84
|
+
const vopts = { dsaEncoding: "ieee-p1363" };
|
|
85
|
+
const ok = crypto.verify("SHA256", input, { key: pub, ...vopts }, sig)
|
|
86
|
+
|| (s > P256_N >> 1n && crypto.verify("SHA256", input, { key: pub, ...vopts }, (() => {
|
|
87
|
+
const alt = Buffer.from(sig);
|
|
88
|
+
Buffer.from((P256_N - s).toString(16).padStart(64, "0"), "hex").copy(alt, 32);
|
|
89
|
+
return alt;
|
|
90
|
+
})()));
|
|
91
|
+
return { ok, header, payload };
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false };
|
|
94
|
+
}
|
|
95
|
+
}
|