mslxdff 0.1.130 → 0.1.132
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/chat/cooling.js +0 -18
- package/src/chat/gateway.js +9 -3
- package/src/cli/commands/provider/add.js +2 -2
- package/src/cli/commands/provider/config.js +1 -24
- package/src/cli/commands/provider/index.js +1 -2
- package/src/cli/commands/provider/keys.js +2 -2
- package/src/cli/provider-row.js +4 -6
- package/src/cli/status.js +1 -1
- package/src/metrics.js +0 -1
- package/src/providers/share-keys.js +15 -19
- package/src/providers/workbuddy/account-store.js +90 -11
- package/src/providers/workbuddy/auth.js +18 -57
- package/src/providers/workbuddy/index.js +21 -58
- package/src/routes/chat/relay-pipeline.js +4 -3
- package/src/routes/chat/via-route-handler.js +4 -8
- package/src/routes/peers.js +1 -1
- package/src/routes/stream.js +23 -35
- package/src/state/facade.js +1 -3
- package/src/state/merge.js +0 -1
- package/src/state/provider-config.js +1 -2
- package/src/state/schemas/provider.js +10 -14
- package/src/state/store.js +1 -1
package/package.json
CHANGED
package/src/chat/cooling.js
CHANGED
|
@@ -79,21 +79,3 @@ export function createCooling({
|
|
|
79
79
|
|
|
80
80
|
return { isCooling, isCoolingAsync, recordError, recordChatError, recordOk, recordChatOk };
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
// 默认实例(对接真实 state.js)
|
|
84
|
-
let _default = null;
|
|
85
|
-
export function getDefaultCooling() {
|
|
86
|
-
if (_default) return _default;
|
|
87
|
-
// 懒加载 state,避免循环
|
|
88
|
-
_default = createCooling({
|
|
89
|
-
loadModelErrors: () => {
|
|
90
|
-
try { const s = require("../state.js"); return s.loadModelErrors(); } catch { return {}; }
|
|
91
|
-
},
|
|
92
|
-
saveModelErrors: (o) => { try { const s = require("../state.js"); s.saveModelErrors(o); } catch {} },
|
|
93
|
-
loadModelLatencies: () => { try { const s = require("../state.js"); return s.loadModelLatencies(); } catch { return {}; } },
|
|
94
|
-
saveModelLatencies: (o) => { try { const s = require("../state.js"); s.saveModelLatencies(o); } catch {} },
|
|
95
|
-
flush: () => { try { const s = require("../state.js"); s.flushStateSync(); } catch {} },
|
|
96
|
-
now: () => Date.now(),
|
|
97
|
-
});
|
|
98
|
-
return _default;
|
|
99
|
-
}
|
package/src/chat/gateway.js
CHANGED
|
@@ -24,8 +24,14 @@ export function createGatewayClient({
|
|
|
24
24
|
return String(loaded?.token || "").trim();
|
|
25
25
|
} catch { return ""; }
|
|
26
26
|
});
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// 默认实现与 loadToken/readModelsJson 同款:懒加载 state(ESM 里没有 require)
|
|
28
|
+
// Note: 这里曾用 require 导致 state.port 被静默忽略 — 见 .agents/notes/implemented/bug-fix/2026-09-17-esm-dead-require-and-orphan-cooling.md
|
|
29
|
+
const _getPort = getPort || (async () => {
|
|
30
|
+
try {
|
|
31
|
+
const s = await import("../state.js");
|
|
32
|
+
const p = s.getPort();
|
|
33
|
+
if (Number.isInteger(p) && p > 0) return p;
|
|
34
|
+
} catch {}
|
|
29
35
|
const v = Number(env.MSLXDFF_PORT);
|
|
30
36
|
if (Number.isInteger(v) && v > 0) return v;
|
|
31
37
|
return defaultPort;
|
|
@@ -48,7 +54,7 @@ export function createGatewayClient({
|
|
|
48
54
|
let token = "";
|
|
49
55
|
try {
|
|
50
56
|
token = String((await _loadToken()) || "").trim();
|
|
51
|
-
const p = _getPort();
|
|
57
|
+
const p = await _getPort();
|
|
52
58
|
if (Number.isInteger(p) && p > 0) port = p;
|
|
53
59
|
} catch {}
|
|
54
60
|
if (!token) {
|
|
@@ -16,7 +16,7 @@ export async function handleProviderAdd(id, sub, rest) {
|
|
|
16
16
|
console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
|
-
const { saveProviderConfig, loadProviderConfig
|
|
19
|
+
const { saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
|
|
20
20
|
const { normalizeProviderId } = await import("../../../providers/model-id.js");
|
|
21
21
|
const nid = normalizeProviderId(gid);
|
|
22
22
|
if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
|
|
@@ -98,7 +98,7 @@ export async function handleProviderAdd(id, sub, rest) {
|
|
|
98
98
|
console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
|
|
99
99
|
if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
|
|
100
100
|
else console.log(` allowedModels: (none — BLOCKED, otherwise unusable) → mslxdff -provider ${nid} allowlist set <model...> OR mslxdff -provider ${nid} allowAny on (allow all)`);
|
|
101
|
-
console.log(` share:
|
|
101
|
+
console.log(` share: 借出(默认,key 随转发自动附带,ADR-0019)`);
|
|
102
102
|
console.log(` allowAny: OFF (secure, empty allowlist = 403 block before upstream) — enable via: mslxdff -provider ${nid} allowAny on`);
|
|
103
103
|
console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
|
|
104
104
|
if (allowedModels.length) console.log(` opencode 中使用: mslxdff -setto opencode ${nid}/${allowedModels[0]} (同步进 opencode.json,会一并设为默认模型)`);
|
|
@@ -5,7 +5,7 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
5
5
|
console.error("opencode 是内置供应商,不能删除");
|
|
6
6
|
process.exit(1);
|
|
7
7
|
}
|
|
8
|
-
const { loadProviderConfigs, saveProviderConfig
|
|
8
|
+
const { loadProviderConfigs, saveProviderConfig } = await import("../../../state.js");
|
|
9
9
|
const { isPidAlive, readPid } = await import("../../../daemon.js");
|
|
10
10
|
const configs = loadProviderConfigs();
|
|
11
11
|
if (!configs[id] && !configs[nid]) {
|
|
@@ -33,16 +33,9 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
33
33
|
writeStateImmediate(file, { providerKeys: nk });
|
|
34
34
|
changed = true;
|
|
35
35
|
}
|
|
36
|
-
if (raw.providerShareKeys && raw.providerShareKeys[id] !== undefined) {
|
|
37
|
-
const ns = { ...raw.providerShareKeys };
|
|
38
|
-
delete ns[id];
|
|
39
|
-
writeStateImmediate(file, { providerShareKeys: ns });
|
|
40
|
-
changed = true;
|
|
41
|
-
}
|
|
42
36
|
// 清理模型错误/延迟中该供应商前缀的条目(可选,不阻塞)
|
|
43
37
|
void changed;
|
|
44
38
|
} catch {}
|
|
45
|
-
try { saveProviderShareKeys(id, false); } catch {}
|
|
46
39
|
console.log(`已删除供应商: ${id} — 配置已清空`);
|
|
47
40
|
// 需要重启才生效,自动重启
|
|
48
41
|
const pid = readPid();
|
|
@@ -122,21 +115,5 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
122
115
|
console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
|
|
123
116
|
process.exit(0);
|
|
124
117
|
}
|
|
125
|
-
if (sub === "share") {
|
|
126
|
-
const { loadProviderShareKeys, saveProviderShareKeys } = await import("../../../state.js");
|
|
127
|
-
const on = rest[1];
|
|
128
|
-
if (!on) {
|
|
129
|
-
console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
|
|
130
|
-
process.exit(0);
|
|
131
|
-
}
|
|
132
|
-
if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
|
|
133
|
-
console.error("usage: mslxdff -provider openrouter share on|off");
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
const state = ["on", "1", "true"].includes(String(on).toLowerCase());
|
|
137
|
-
saveProviderShareKeys(id, state);
|
|
138
|
-
console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
|
|
139
|
-
process.exit(0);
|
|
140
|
-
}
|
|
141
118
|
return false;
|
|
142
119
|
}
|
|
@@ -25,13 +25,12 @@ export async function handleProvider(args) {
|
|
|
25
25
|
const sub = args[idx + 2];
|
|
26
26
|
const rest = args.slice(idx + 2);
|
|
27
27
|
if (!id) {
|
|
28
|
-
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|
|
|
28
|
+
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|set-url|allowlist|allowAny]");
|
|
29
29
|
console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
|
|
30
30
|
console.error(" mslxdff -provider openrouter add sk-4 append one key");
|
|
31
31
|
console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
|
|
32
32
|
console.error(" mslxdff -provider openrouter list list all keys (masked)");
|
|
33
33
|
console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
|
|
34
|
-
console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
|
|
35
34
|
console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
|
|
36
35
|
console.error(" mslxdff -provider openrouter set-models-path /v1/models");
|
|
37
36
|
console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export async function handleProviderKeys(id, sub, rest, args) {
|
|
2
2
|
if (sub === "list" || sub === "status") {
|
|
3
|
-
const { loadProviderKeys, loadProviderConfig,
|
|
3
|
+
const { loadProviderKeys, loadProviderConfig, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
|
|
4
4
|
const keys = loadProviderKeys(id);
|
|
5
5
|
const cfg = loadProviderConfig(id);
|
|
6
6
|
const allowed = loadProviderAllowedModels(id);
|
|
@@ -15,7 +15,7 @@ export async function handleProviderKeys(id, sub, rest, args) {
|
|
|
15
15
|
} else {
|
|
16
16
|
console.log(` keys: (no keys configured)`);
|
|
17
17
|
}
|
|
18
|
-
console.log(` share keys to peers:
|
|
18
|
+
console.log(` share keys to peers: 借出(默认,key 随转发自动附带,ADR-0019)`);
|
|
19
19
|
console.log(` allowAnyModels: ${allowAny ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = BLOCK ALL)"} (mslxdff -provider ${id} allowAny on|off)`);
|
|
20
20
|
if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
|
|
21
21
|
else console.log(` allowedModels: (none — ${allowAny ? "allow all" : "BLOCK ALL"}) (mslxdff -provider ${id} allowlist set <model...> or allowAny on)`);
|
package/src/cli/provider-row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels,
|
|
2
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } from "../state.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
|
|
@@ -37,12 +37,10 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
37
37
|
const cfg = configs[gid];
|
|
38
38
|
let keys = [];
|
|
39
39
|
let baseUrl = "";
|
|
40
|
-
let share = false;
|
|
41
40
|
let allowed = [];
|
|
42
41
|
let allowAny = false;
|
|
43
42
|
try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
44
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 || ""; }
|
|
45
|
-
try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
46
44
|
try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
47
45
|
try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
48
46
|
// openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
|
|
@@ -61,7 +59,7 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
61
59
|
} else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
|
|
62
60
|
note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
|
|
63
61
|
}
|
|
64
|
-
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny,
|
|
62
|
+
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, note, authCount: auths.length });
|
|
65
63
|
}
|
|
66
64
|
return rows;
|
|
67
65
|
}
|
|
@@ -94,8 +92,8 @@ export function formatProviderRow(p) {
|
|
|
94
92
|
const more = p.allowed.length > 2 ? ` …+${p.allowed.length - 2}` : "";
|
|
95
93
|
allowInfo = `allow ${p.allowed.length} → ${head}${more}`;
|
|
96
94
|
}
|
|
97
|
-
// share
|
|
98
|
-
const shareInfo = p.id === "opencode" ? "无法共享" :
|
|
95
|
+
// share(ADR-0019:默认借出,无开关)
|
|
96
|
+
const shareInfo = p.id === "opencode" ? "无法共享" : "共享 借出";
|
|
99
97
|
// base
|
|
100
98
|
const base = p.baseUrl && p.baseUrl !== "(none)" ? p.baseUrl : "(none)";
|
|
101
99
|
const baseLine = ` └ ${base}${p.note ? ` · ${p.note}` : ""}`;
|
package/src/cli/status.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { readPid, readPidVersion, isPidAlive, pidFile } from "../daemon.js";
|
|
5
5
|
import { defaultStateFile, getPort, loadGroupsJoined, loadModelErrors, loadModelStats, loadModelPicks } from "../state.js";
|
|
6
6
|
import { resolvePort } from "../server.js";
|
|
7
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels,
|
|
7
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderBaseUrl } from "../state.js";
|
|
8
8
|
import { loadToken } from "../state.js";
|
|
9
9
|
import { logDir } from "../logs.js";
|
|
10
10
|
import { createGroupsService } from "../groups.js";
|
package/src/metrics.js
CHANGED
|
@@ -1,29 +1,25 @@
|
|
|
1
|
-
// ADR-
|
|
1
|
+
// ADR-0019:借道 = 用你的 key —— 转发时自动借出,无开关、无白名单(组内互信是前提)。
|
|
2
2
|
// header: `x-mslxdff-share-keys: <providerId>=<key1>,<key2>;<providerId2>=<key3>`
|
|
3
3
|
import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
|
|
4
|
-
import {
|
|
4
|
+
import { classifyProvider } from "./classify.js";
|
|
5
|
+
import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
|
|
5
6
|
|
|
6
7
|
export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
9
|
+
// 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
|
|
10
|
+
const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
|
|
11
|
+
|
|
12
|
+
// 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
|
|
13
|
+
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy,本就不走组员)、刷新型凭据。
|
|
12
14
|
export function shareableProviderIds({ file } = {}) {
|
|
13
|
-
const ids =
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
ids.add(id);
|
|
20
|
-
}
|
|
21
|
-
return [...ids];
|
|
22
|
-
}
|
|
23
|
-
for (const id of ["openrouter"]) {
|
|
24
|
-
if (id !== DEFAULT_PROVIDER && loadProviderKeys(id, { file }).length && loadProviderShareKeys(id, { file })) ids.add(id);
|
|
15
|
+
const ids = [];
|
|
16
|
+
for (const id of listProviderIdsWithKeys({ file })) {
|
|
17
|
+
if (id === DEFAULT_PROVIDER) continue;
|
|
18
|
+
if (NEVER_SHARE_IDS.has(id)) continue;
|
|
19
|
+
try { if (classifyProvider(id) === "local-only") continue; } catch {}
|
|
20
|
+
ids.push(id);
|
|
25
21
|
}
|
|
26
|
-
return
|
|
22
|
+
return ids;
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
// 组装 share header 值(只含 shareable 且当前请求命中前缀的供应商)。
|
|
@@ -1,19 +1,33 @@
|
|
|
1
|
-
// WorkBuddy
|
|
2
|
-
// token-auto
|
|
1
|
+
// WorkBuddy 账号落盘单一源:auths/workbuddy-<uid>.json(0600 tmp+rename)+ state.json keys/auths 按 uid 去重。
|
|
2
|
+
// token-auto / device-login / 刷新(服务端 saveFn 与 CLI 定时任务)共用同一实现,安全细节只维护一份。
|
|
3
|
+
// Note: 刷新落盘只走 applyTokenRefresh,别再各自内联一套 — 见 .agents/notes/implemented/architecture/2026-09-17-workbuddy-persist-single-seam.md
|
|
3
4
|
import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "node:fs";
|
|
4
5
|
import { join, dirname } from "node:path";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
7
|
+
import { isTestEnv } from "../../state/store.js";
|
|
6
8
|
|
|
7
9
|
export function resolveAuthDir() {
|
|
8
10
|
if (process.env.WORKBUDDY_AUTH_DIR) return process.env.WORKBUDDY_AUTH_DIR;
|
|
9
11
|
const sf = process.env.MSLXDFF_STATE_FILE || "";
|
|
10
|
-
if (sf.includes("mslxdff-test") ||
|
|
12
|
+
if (sf.includes("mslxdff-test") || isTestEnv()) return join(tmpdir(), "mslxdff-test-auths");
|
|
11
13
|
if (sf && sf.includes("mslxdff-")) return join(dirname(sf), "auths");
|
|
12
14
|
return join(process.cwd(), "auths");
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
function jwtExp(token) {
|
|
18
|
+
try {
|
|
19
|
+
const payload = String(token || "").split(".")[1];
|
|
20
|
+
if (!payload) return 0;
|
|
21
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
22
|
+
const pad = b64.length % 4;
|
|
23
|
+
if (pad) b64 += "=".repeat(4 - pad);
|
|
24
|
+
return Number(JSON.parse(Buffer.from(b64, "base64").toString("utf8")).exp || 0);
|
|
25
|
+
} catch {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeAccountFile({ uid, accessToken, refreshToken = "", expiresAt = 0, domain = "www.codebuddy.cn", enterpriseId = "", nickname = "" }) {
|
|
17
31
|
const authDir = resolveAuthDir();
|
|
18
32
|
mkdirSync(authDir, { recursive: true });
|
|
19
33
|
const exp = expiresAt || Math.floor(Date.now() / 1000) + 5184000;
|
|
@@ -26,16 +40,81 @@ export async function saveWorkbuddyAccount({ uid, enterpriseId = "", nickname =
|
|
|
26
40
|
renameSync(tmp, fp);
|
|
27
41
|
} catch {
|
|
28
42
|
writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
43
|
+
try { unlinkSync(tmp); } catch {} // 失败路径别把含凭据的 .tmp 留在盘上
|
|
29
44
|
}
|
|
30
|
-
|
|
45
|
+
return fp;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// state.json 落盘单一实现:按 uid 配对更新(存在→更新该账号行,不存在→追加);extraKeys 为没有 auth 行的裸 key,原样保留。
|
|
49
|
+
async function persistAccounts(accounts, file, extraKeys = []) {
|
|
31
50
|
const { loadProviderConfigs, saveProviderConfig } = await import("../../state.js");
|
|
32
51
|
const cfg = (loadProviderConfigs(file ? { file } : {})?.workbuddy) || {};
|
|
33
52
|
const keys = [...(Array.isArray(cfg.keys) ? cfg.keys : [])];
|
|
34
53
|
const auths = [...(Array.isArray(cfg.auths) ? cfg.auths : [])];
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
let updated = 0;
|
|
55
|
+
for (const acc of accounts) {
|
|
56
|
+
if (!acc?.uid || !acc?.accessToken) continue;
|
|
57
|
+
const row = { uid: acc.uid, domain: acc.domain || "www.codebuddy.cn", enterpriseId: acc.enterpriseId || "", refreshToken: acc.refreshToken || "" };
|
|
58
|
+
const atIdx = auths.findIndex((a) => a?.uid === acc.uid);
|
|
59
|
+
if (atIdx >= 0) { keys[atIdx] = acc.accessToken; auths[atIdx] = row; updated += 1; }
|
|
60
|
+
else { keys.push(acc.accessToken); auths.push(row); }
|
|
61
|
+
}
|
|
62
|
+
for (const k of extraKeys) {
|
|
63
|
+
if (k && !keys.includes(k)) keys.push(k);
|
|
64
|
+
}
|
|
39
65
|
saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl || "https://copilot.tencent.com", keys, auths }, file ? { file } : {});
|
|
40
|
-
return {
|
|
66
|
+
return { keys, auths, updated };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function saveWorkbuddyAccount({ uid, enterpriseId = "", nickname = "", accessToken, refreshToken = "", expiresAt = 0, domain = "www.codebuddy.cn", file } = {}) {
|
|
70
|
+
if (!uid || !accessToken) throw new Error("saveWorkbuddyAccount: 缺少 uid/accessToken");
|
|
71
|
+
const fp = writeAccountFile({ uid, accessToken, refreshToken, expiresAt, domain, enterpriseId, nickname });
|
|
72
|
+
const st = await persistAccounts([{ uid, accessToken, refreshToken, domain, enterpriseId }], file);
|
|
73
|
+
return { file: fp, accounts: st.keys.length, updated: st.updated > 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 由平行数组(keys[i] ↔ authList[i],provider 构造时即如此)还原账号列表;没有 auth 行的裸 key 走 extraKeys 原样保留。
|
|
77
|
+
function accountsFromArrays(keys, authList) {
|
|
78
|
+
const accounts = [];
|
|
79
|
+
const extraKeys = [];
|
|
80
|
+
const n = Math.max(keys.length, authList.length);
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
const a = authList[i];
|
|
83
|
+
if (a && typeof a === "object" && a.uid) {
|
|
84
|
+
accounts.push({ uid: a.uid, accessToken: keys[i] || "", refreshToken: a.refreshToken || "", domain: a.domain, enterpriseId: a.enterpriseId });
|
|
85
|
+
} else if (keys[i]) {
|
|
86
|
+
extraKeys.push(keys[i]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { accounts, extraKeys };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 刷新后的 token 落盘:keys 与 authList 是两条平行数组,按下标更新命中的槽位(旧 key 命中优先,
|
|
93
|
+
// 否则按 uid 命中;都没有 → 追加),再把整表经 persistAccounts 单点落 state + auths 文件。
|
|
94
|
+
// 服务端 saveFn 与 CLI/定时任务共用。
|
|
95
|
+
export async function applyTokenRefresh({ uid, oldKey, newToken, refreshToken = "", domain = "www.codebuddy.cn", enterpriseId = "", auth = null, keys = [], authList = [], file } = {}) {
|
|
96
|
+
if (!uid || !newToken) return { updated: false, accounts: keys.length, file: null };
|
|
97
|
+
const keyIdx = keys.indexOf(oldKey);
|
|
98
|
+
const uidIdx = authList.findIndex((a) => a && String(a.uid) === String(uid));
|
|
99
|
+
// 平行数组约定:命中任一侧即用该下标(keys[i] 与 authList[i] 同属一个账号,不会覆盖他人)
|
|
100
|
+
const idx = keyIdx >= 0 ? keyIdx : uidIdx;
|
|
101
|
+
const matched = idx >= 0;
|
|
102
|
+
if (idx >= 0) keys[idx] = newToken;
|
|
103
|
+
else if (!keys.includes(newToken)) keys.push(newToken);
|
|
104
|
+
if (uidIdx >= 0) {
|
|
105
|
+
const prev = authList[uidIdx] && typeof authList[uidIdx] === "object" ? authList[uidIdx] : null;
|
|
106
|
+
authList[uidIdx] = {
|
|
107
|
+
...(prev || auth || {}),
|
|
108
|
+
uid,
|
|
109
|
+
domain: prev?.domain || auth?.domain || domain,
|
|
110
|
+
enterpriseId: prev?.enterpriseId || auth?.enterpriseId || enterpriseId,
|
|
111
|
+
refreshToken,
|
|
112
|
+
};
|
|
113
|
+
} else {
|
|
114
|
+
authList.push({ ...(auth || {}), uid, domain: auth?.domain || domain, enterpriseId: auth?.enterpriseId || enterpriseId, refreshToken });
|
|
115
|
+
}
|
|
116
|
+
const fp = writeAccountFile({ uid, accessToken: newToken, refreshToken, expiresAt: jwtExp(newToken), domain, enterpriseId });
|
|
117
|
+
const { accounts, extraKeys } = accountsFromArrays(keys, authList);
|
|
118
|
+
const st = await persistAccounts(accounts, file, extraKeys);
|
|
119
|
+
return { updated: matched, accounts: st.keys.length, file: fp };
|
|
41
120
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { joinUrl } from "../base.js";
|
|
2
2
|
import { compatFetch } from "../../compat.js";
|
|
3
3
|
import { WORKBUDDY_DEFAULT_BASE_URL } from "../../state/schemas/provider.js";
|
|
4
|
+
import { applyTokenRefresh } from "./account-store.js";
|
|
4
5
|
|
|
5
6
|
export function decodeJwtExp(token) {
|
|
6
7
|
try {
|
|
@@ -45,11 +46,7 @@ export function createAuthService({
|
|
|
45
46
|
saveFn,
|
|
46
47
|
} = {}) {
|
|
47
48
|
const resolvedBase = baseUrl ? String(baseUrl).trim().replace(/\/+$/, "") : WORKBUDDY_DEFAULT_BASE_URL;
|
|
48
|
-
|
|
49
|
-
// 兼容层统一取(undici 优先,老 Node 兜底)
|
|
50
|
-
fetchImpl = compatFetch;
|
|
51
|
-
}
|
|
52
|
-
// lazy resolve UndiciFetch if not provided
|
|
49
|
+
// 兼容层统一取(undici 优先,老 Node 兜底)
|
|
53
50
|
if (!fetchImpl) fetchImpl = compatFetch;
|
|
54
51
|
|
|
55
52
|
const inflightRefresh = new Map();
|
|
@@ -84,50 +81,29 @@ export function createAuthService({
|
|
|
84
81
|
if (j.code === 0 && j.data?.accessToken) {
|
|
85
82
|
const newAt = j.data.accessToken;
|
|
86
83
|
const newRt = j.data.refreshToken || rt;
|
|
87
|
-
// attempt to persist if store provided
|
|
84
|
+
// attempt to persist if store provided(落盘细节单一源:account-store.applyTokenRefresh)
|
|
88
85
|
try {
|
|
89
86
|
if (saveFn) {
|
|
90
87
|
await saveFn({ newAt, newRt, uid, oldKey: key, auth });
|
|
91
88
|
} else if (store && typeof store.save === "function") {
|
|
92
89
|
await store.save({ newAt, newRt, uid, oldKey: key, auth });
|
|
93
90
|
} else if (store && Array.isArray(store.keys) && Array.isArray(store.authList)) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const { tmpdir } = await import("node:os");
|
|
107
|
-
const isTest = process.env.NODE_ENV === "test" || (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test"));
|
|
108
|
-
const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTest ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
|
|
109
|
-
mkdirSync(authDir, { recursive: true });
|
|
110
|
-
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now() / 1000) + 5184000; } })();
|
|
111
|
-
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
|
112
|
-
const fp = join(authDir, `workbuddy-${uid}.json`);
|
|
113
|
-
const tmp = fp + ".tmp";
|
|
114
|
-
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
115
|
-
try {
|
|
116
|
-
if (existsSync(fp)) {
|
|
117
|
-
const { unlinkSync, renameSync } = await import("node:fs");
|
|
118
|
-
unlinkSync(fp);
|
|
119
|
-
renameSync(tmp, fp);
|
|
120
|
-
} else {
|
|
121
|
-
const { renameSync } = await import("node:fs");
|
|
122
|
-
renameSync(tmp, fp);
|
|
123
|
-
}
|
|
124
|
-
} catch {
|
|
125
|
-
writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
126
|
-
}
|
|
127
|
-
} catch {}
|
|
128
|
-
}
|
|
91
|
+
await applyTokenRefresh({
|
|
92
|
+
uid,
|
|
93
|
+
oldKey: key,
|
|
94
|
+
newToken: newAt,
|
|
95
|
+
refreshToken: newRt,
|
|
96
|
+
domain: auth.domain || "www.codebuddy.cn",
|
|
97
|
+
enterpriseId: auth.enterpriseId || "",
|
|
98
|
+
auth,
|
|
99
|
+
keys: store.keys,
|
|
100
|
+
authList: store.authList,
|
|
101
|
+
file: file || undefined,
|
|
102
|
+
});
|
|
129
103
|
}
|
|
130
|
-
} catch {
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[workbuddy] token 落盘失败(刷新已成功,重启将丢失): ${String(err?.message || err).slice(0, 200)}`);
|
|
106
|
+
}
|
|
131
107
|
return newAt;
|
|
132
108
|
}
|
|
133
109
|
} catch {}
|
|
@@ -155,18 +131,3 @@ export function createAuthService({
|
|
|
155
131
|
|
|
156
132
|
return { refreshTokenFor, maybeProactiveRefresh, decodeJwtExp, isAuthError, isInsufficientStatus, _inflight: inflightRefresh };
|
|
157
133
|
}
|
|
158
|
-
|
|
159
|
-
function awaitImportUndici() {
|
|
160
|
-
try {
|
|
161
|
-
// dynamic to avoid top-level await
|
|
162
|
-
let UndiciFetch = null;
|
|
163
|
-
try {
|
|
164
|
-
// eslint-disable-next-line no-undef
|
|
165
|
-
const mod = globalThis.__mslxdff_undici || null;
|
|
166
|
-
if (mod) return mod;
|
|
167
|
-
} catch {}
|
|
168
|
-
return { UndiciFetch: null };
|
|
169
|
-
} catch {
|
|
170
|
-
return { UndiciFetch: null };
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createKeyRing } from "../keyring.js";
|
|
2
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl,
|
|
3
|
-
import { existsSync,
|
|
2
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { compatFetch } from "../../compat.js";
|
|
5
|
-
import { join
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
7
6
|
import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
|
|
8
7
|
import { createAuthService, isAuthError, isInsufficientStatus, decodeJwtExp } from "./auth.js";
|
|
8
|
+
import { applyTokenRefresh, resolveAuthDir } from "./account-store.js";
|
|
9
9
|
import { createChatService } from "./chat.js";
|
|
10
10
|
import { createModelsService } from "./models.js";
|
|
11
11
|
import { createBalanceCache, getCachedBalance as defaultGetCached, setCachedBalance as defaultSetCached } from "./balance.js";
|
|
@@ -13,15 +13,6 @@ import { defaultLogger } from "./rotation-log.js";
|
|
|
13
13
|
|
|
14
14
|
const { UndiciAgent, UndiciFetch } = getUndici();
|
|
15
15
|
|
|
16
|
-
function isTestEnv() {
|
|
17
|
-
if (process.env.NODE_ENV === "test") return true;
|
|
18
|
-
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
19
|
-
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|
|
20
|
-
if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
|
|
21
|
-
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
function resolveBaseUrl(baseUrl) {
|
|
26
17
|
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
27
18
|
const env = loadProviderBaseUrl("workbuddy");
|
|
@@ -71,7 +62,7 @@ export function createWorkbuddyProvider({
|
|
|
71
62
|
|
|
72
63
|
if (!authList.length && !keys.length) {
|
|
73
64
|
try {
|
|
74
|
-
const authDir =
|
|
65
|
+
const authDir = resolveAuthDir();
|
|
75
66
|
if (existsSync(authDir)) {
|
|
76
67
|
const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
|
|
77
68
|
for (const f of files) {
|
|
@@ -112,48 +103,28 @@ export function createWorkbuddyProvider({
|
|
|
112
103
|
|
|
113
104
|
const logger = loggerOpt || defaultLogger;
|
|
114
105
|
|
|
115
|
-
// authService with saveFn that mutates outer keys/authList/ring and persists
|
|
116
106
|
const authService = createAuthService({
|
|
117
107
|
baseUrl: resolvedBase,
|
|
118
108
|
fetchImpl,
|
|
119
109
|
clock,
|
|
120
110
|
dispatcher,
|
|
121
111
|
file,
|
|
112
|
+
// 落盘细节单一源:account-store.applyTokenRefresh(数组就地更新 + state + auths 文件)
|
|
122
113
|
saveFn: async ({ newAt, newRt, uid, oldKey, auth }) => {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
if (existsSync(fp)) {
|
|
139
|
-
const { unlinkSync, renameSync } = await import("node:fs");
|
|
140
|
-
unlinkSync(fp);
|
|
141
|
-
renameSync(tmp, fp);
|
|
142
|
-
} else {
|
|
143
|
-
const { renameSync } = await import("node:fs");
|
|
144
|
-
renameSync(tmp, fp);
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
148
|
-
}
|
|
149
|
-
} catch {}
|
|
150
|
-
} else {
|
|
151
|
-
if (!keys.includes(newAt)) {
|
|
152
|
-
keys.push(newAt);
|
|
153
|
-
authList.push({ ...(auth || {}), uid, refreshToken: newRt });
|
|
154
|
-
try { ring.replace(oldKey, newAt); } catch {}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
114
|
+
const r = await applyTokenRefresh({
|
|
115
|
+
uid,
|
|
116
|
+
oldKey,
|
|
117
|
+
newToken: newAt,
|
|
118
|
+
refreshToken: newRt,
|
|
119
|
+
domain: auth?.domain || "www.codebuddy.cn",
|
|
120
|
+
enterpriseId: auth?.enterpriseId || "",
|
|
121
|
+
auth,
|
|
122
|
+
keys,
|
|
123
|
+
authList,
|
|
124
|
+
file,
|
|
125
|
+
});
|
|
126
|
+
try { ring.replace(oldKey, newAt); } catch {}
|
|
127
|
+
return r;
|
|
157
128
|
},
|
|
158
129
|
});
|
|
159
130
|
|
|
@@ -205,15 +176,7 @@ export function createWorkbuddyProvider({
|
|
|
205
176
|
}
|
|
206
177
|
|
|
207
178
|
async function chatWithKeys(body, keysOverride) {
|
|
208
|
-
|
|
209
|
-
try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
|
|
210
|
-
})();
|
|
211
|
-
// allow injected balanceCache/logger to bypass share check for isolated tests:
|
|
212
|
-
// if keysOverride provided and logger is injected (test mode), permit share without state flag
|
|
213
|
-
const isTestInjection = !!balanceCacheOpt || !!loggerOpt;
|
|
214
|
-
if (!shareOn && !isTestInjection) {
|
|
215
|
-
return chatSvc.runChat(body, ring);
|
|
216
|
-
}
|
|
179
|
+
// 借入的 key(ADR-0019:转发时自动附带)——用隔离的 tmp ring/auth,不改动本机 ring。
|
|
217
180
|
// Isolated tmp ring and auth without mutating shared arrays
|
|
218
181
|
const tmpKeys = [...keysOverride].filter((k) => typeof k === "string" && k.trim().length).map((k) => k.trim());
|
|
219
182
|
const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { runHook } from "../../plugins.js";
|
|
2
2
|
import { recordModelStats } from "../../state.js";
|
|
3
3
|
import { normalizeFullId } from "../../providers/model-id.js";
|
|
4
|
-
import { computeMetrics
|
|
4
|
+
import { computeMetrics } from "../../metrics.js";
|
|
5
5
|
|
|
6
6
|
// 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
|
|
7
7
|
// (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
|
|
@@ -109,11 +109,12 @@ export function createRelayPipeline({
|
|
|
109
109
|
totalMs: out.totalMs,
|
|
110
110
|
aborted: out.aborted,
|
|
111
111
|
interrupted: out.interrupted ?? false,
|
|
112
|
+
timedOut: out.timedOut ?? false,
|
|
112
113
|
detail: out.detail ?? null,
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
// 5a. 首块超时未写字节 →
|
|
116
|
-
if (
|
|
116
|
+
// 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
|
|
117
|
+
if (out.timedOut === true) {
|
|
117
118
|
if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${streamTimeoutMs}ms` }); } catch {}
|
|
118
119
|
try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms`); } catch {}
|
|
119
120
|
_evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
|
|
@@ -2,8 +2,7 @@ import { createRelayPipeline } from "./relay-pipeline.js";
|
|
|
2
2
|
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
3
3
|
import { buildFallbackInfo } from "../fallback.js";
|
|
4
4
|
import { getViaRoute } from "../../bench/via-routes.js";
|
|
5
|
-
import {
|
|
6
|
-
import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
5
|
+
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
7
6
|
import { errMsg } from "../helpers.js";
|
|
8
7
|
import { compatFetch } from "../../compat.js";
|
|
9
8
|
|
|
@@ -61,12 +60,9 @@ export async function handleViaRoute({
|
|
|
61
60
|
evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
|
|
62
61
|
// 单路径转发,不并发:直接打 best peer
|
|
63
62
|
const hops = handlerCtx.hops || 0;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const keys = loadProviderKeys(providerId) || [];
|
|
68
|
-
if (keys.length) shareHeader = `${providerId}=${keys.join(",")}`;
|
|
69
|
-
} catch {}
|
|
63
|
+
// ADR-0019:借道 = 用 A 的 key —— 转发时自动附带(组内互信,无开关),与 peers.js 共用同一组装点。
|
|
64
|
+
// Note: 见 .agents/notes/implemented/architecture/2026-09-17-share-keys-no-switch.md
|
|
65
|
+
const shareHeader = buildShareKeysHeader(model);
|
|
70
66
|
const controller = new AbortController();
|
|
71
67
|
const timer = setTimeout(() => controller.abort(new Error("via-route timeout 30000ms")), 30000);
|
|
72
68
|
let upRes;
|
package/src/routes/peers.js
CHANGED
|
@@ -122,7 +122,7 @@ async function forwardToPeer(peer, body, model, hops, controller = new AbortCont
|
|
|
122
122
|
"x-mslxdff-model-lock": model,
|
|
123
123
|
"Accept": "text/event-stream",
|
|
124
124
|
};
|
|
125
|
-
// ADR-
|
|
125
|
+
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
126
126
|
const shareHeader = buildShareKeysHeader(model);
|
|
127
127
|
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
128
128
|
const dispatcher = getPeerDispatcher();
|
package/src/routes/stream.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
2
|
import { applyFallbackHeaders, enrichNonStreamJson, enrichSseChunkText } from "./fallback.js";
|
|
3
3
|
import { json } from "./helpers.js";
|
|
4
|
+
import { extractUsageFromJson, extractUsageFromSseText } from "../metrics.js";
|
|
4
5
|
|
|
5
6
|
// SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
|
|
6
7
|
// 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
|
|
@@ -163,8 +164,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
163
164
|
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
164
165
|
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
165
166
|
if (m) detail.sawFinishReason = m[1];
|
|
166
|
-
// 尝试提取 usage
|
|
167
|
-
if (txt.includes("\"usage\"") || txt.includes("prompt_tokens")) {
|
|
167
|
+
// 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
|
|
168
|
+
if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
|
|
168
169
|
try {
|
|
169
170
|
const lines = txt.split("\n");
|
|
170
171
|
for (const line of lines) {
|
|
@@ -172,28 +173,22 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
172
173
|
if (!t.startsWith("data:")) continue;
|
|
173
174
|
const d = t.slice(5).trim();
|
|
174
175
|
if (d === "[DONE]" || !d) continue;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
const cur = {};
|
|
182
|
-
if (Number.isFinite(pt)) cur.prompt_tokens = pt;
|
|
183
|
-
if (Number.isFinite(ct)) cur.completion_tokens = ct;
|
|
184
|
-
if (Number.isFinite(tt)) cur.total_tokens = tt;
|
|
185
|
-
if (Object.keys(cur).length) detail.usage = cur;
|
|
176
|
+
// 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
|
|
177
|
+
try {
|
|
178
|
+
const j = JSON.parse(d);
|
|
179
|
+
if (!j || typeof j !== "object") continue;
|
|
180
|
+
const u = extractUsageFromJson(j);
|
|
181
|
+
if (u) detail.usage = u;
|
|
186
182
|
// 兜底 chars:从 choices 文本长度累加
|
|
187
183
|
const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
|
|
188
184
|
if (delta) detail.chars += String(delta).length;
|
|
189
|
-
}
|
|
185
|
+
} catch { /* 单行坏帧忽略 */ }
|
|
190
186
|
}
|
|
191
187
|
} catch {}
|
|
192
188
|
} else {
|
|
193
189
|
// 非 usage 的普通 delta 也累 chars
|
|
194
190
|
try {
|
|
195
|
-
const
|
|
196
|
-
const ms = txt2.match(/"content"\s*:\s*"([^"]*)"/g);
|
|
191
|
+
const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
|
|
197
192
|
if (ms) for (const mm of ms) {
|
|
198
193
|
const c = JSON.parse(`{${mm}}`);
|
|
199
194
|
if (c.content) detail.chars += String(c.content).length;
|
|
@@ -250,14 +245,15 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
250
245
|
}
|
|
251
246
|
if (timedOut && !wroteAny) {
|
|
252
247
|
res.removeListener("close", onClose);
|
|
253
|
-
|
|
248
|
+
// Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
|
|
249
|
+
return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
|
|
254
250
|
}
|
|
255
251
|
if ((stalled || tooLong) && wroteAny) {
|
|
256
252
|
interrupted = true;
|
|
257
253
|
detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
|
|
258
254
|
res.removeListener("close", onClose);
|
|
259
255
|
try { res.end(); } catch { /* ignore */ }
|
|
260
|
-
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
|
|
256
|
+
return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
|
|
261
257
|
}
|
|
262
258
|
} else {
|
|
263
259
|
detail.exitReason = "empty-body";
|
|
@@ -267,7 +263,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
267
263
|
finishedNormally = true;
|
|
268
264
|
res.removeListener("close", onClose);
|
|
269
265
|
try { res.end(); } catch { /* ignore */ }
|
|
270
|
-
return { status: 200, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
|
|
266
|
+
return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
|
|
271
267
|
}
|
|
272
268
|
|
|
273
269
|
finishedNormally = true;
|
|
@@ -275,32 +271,24 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
275
271
|
const text = await upRes.text();
|
|
276
272
|
detail.receivedBytes = Buffer.byteLength(text);
|
|
277
273
|
detail.exitReason = "normal-non-stream";
|
|
278
|
-
// 非流式 usage 与 chars
|
|
274
|
+
// 非流式 usage 与 chars 提取(口径与流式分支共用 metrics.js)
|
|
279
275
|
try {
|
|
280
276
|
const parsed = JSON.parse(text);
|
|
281
|
-
const u = parsed
|
|
282
|
-
if (u
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const tt = Number(u.total_tokens ?? u.totalTokens);
|
|
286
|
-
const cur = {};
|
|
287
|
-
if (Number.isFinite(pt)) cur.prompt_tokens = pt;
|
|
288
|
-
if (Number.isFinite(ct)) cur.completion_tokens = ct;
|
|
289
|
-
if (Number.isFinite(tt)) cur.total_tokens = tt;
|
|
290
|
-
if (Object.keys(cur).length) detail.usage = cur;
|
|
291
|
-
if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
|
|
292
|
-
else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
|
|
293
|
-
}
|
|
277
|
+
const u = extractUsageFromJson(parsed);
|
|
278
|
+
if (u) detail.usage = u;
|
|
279
|
+
if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
|
|
280
|
+
else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
|
|
294
281
|
const enriched = enrichNonStreamJson(parsed, fallback);
|
|
295
282
|
json(res, upRes.status, enriched);
|
|
296
283
|
} catch {
|
|
297
284
|
res.statusCode = upRes.status;
|
|
298
285
|
res.setHeader("Content-Type", contentType || "text/plain");
|
|
299
286
|
res.end(text);
|
|
300
|
-
// 纯文本时按长度估 chars
|
|
287
|
+
// 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
|
|
301
288
|
try { detail.chars = text.length; } catch {}
|
|
289
|
+
try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
|
|
302
290
|
}
|
|
303
291
|
// 非流式 ttf 视为 total(一次性返回)
|
|
304
292
|
const totalMs = Math.round(performance.now() - t0);
|
|
305
|
-
return { status: upRes.status, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
|
|
293
|
+
return { status: upRes.status, timedOut: false, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
|
|
306
294
|
}
|
package/src/state/facade.js
CHANGED
|
@@ -5,16 +5,14 @@ export {
|
|
|
5
5
|
WORKBUDDY_DEFAULT_BASE_URL,
|
|
6
6
|
providerKeyEnv,
|
|
7
7
|
providerBaseUrlEnv,
|
|
8
|
-
providerShareEnv,
|
|
9
8
|
loadProviderKeys,
|
|
10
9
|
loadProviderKey,
|
|
10
|
+
listProviderIdsWithKeys,
|
|
11
11
|
saveProviderKeys,
|
|
12
12
|
saveProviderKey,
|
|
13
13
|
addProviderKey,
|
|
14
14
|
removeProviderKey,
|
|
15
15
|
removeProviderKeys,
|
|
16
|
-
loadProviderShareKeys,
|
|
17
|
-
saveProviderShareKeys,
|
|
18
16
|
loadProviderConfigs,
|
|
19
17
|
loadProviderConfig,
|
|
20
18
|
loadProviderBaseUrl,
|
package/src/state/merge.js
CHANGED
|
@@ -56,10 +56,9 @@ export function defaultChatPath(id) {
|
|
|
56
56
|
|
|
57
57
|
export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
|
|
58
58
|
export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
|
|
59
|
-
export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
|
|
60
59
|
|
|
61
60
|
/**
|
|
62
|
-
* validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath
|
|
61
|
+
* validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath(share 已删,key 随转发自动借出)
|
|
63
62
|
* 返回 {ok, clean, errors}
|
|
64
63
|
*/
|
|
65
64
|
export function validateProviderConfig(raw = {}, providerId = "") {
|
|
@@ -9,7 +9,6 @@ export {
|
|
|
9
9
|
defaultChatPath,
|
|
10
10
|
providerKeyEnv,
|
|
11
11
|
providerBaseUrlEnv,
|
|
12
|
-
providerShareEnv,
|
|
13
12
|
} from "../provider-config.js";
|
|
14
13
|
import {
|
|
15
14
|
normalizeBaseUrl as _normalizeBaseUrl,
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
defaultChatPath as _defaultChatPath,
|
|
21
20
|
providerKeyEnv as _providerKeyEnv,
|
|
22
21
|
providerBaseUrlEnv as _providerBaseUrlEnv,
|
|
23
|
-
providerShareEnv as _providerShareEnv,
|
|
24
22
|
WORKBUDDY_DEFAULT_BASE_URL as _WORKBUDDY,
|
|
25
23
|
} from "../provider-config.js";
|
|
26
24
|
|
|
@@ -52,6 +50,16 @@ export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
|
|
|
52
50
|
return [];
|
|
53
51
|
}
|
|
54
52
|
export function loadProviderKey(id, opts = {}) { return loadProviderKeys(id, opts)[0] || ""; }
|
|
53
|
+
// 本节点所有「有 key」的供应商 id(providerConfigs ∪ providerKeys,含 env 覆盖)——
|
|
54
|
+
// 转发时默认借出的候选集合由它枚举;见 src/providers/share-keys.js
|
|
55
|
+
export function listProviderIdsWithKeys({ file = defaultStateFile() } = {}) {
|
|
56
|
+
const st = readState(file);
|
|
57
|
+
const ids = new Set();
|
|
58
|
+
for (const src of [st.providerConfigs, st.providerKeys]) {
|
|
59
|
+
if (src && typeof src === "object" && !Array.isArray(src)) for (const id of Object.keys(src)) ids.add(id);
|
|
60
|
+
}
|
|
61
|
+
return [...ids].filter((id) => loadProviderKeys(id, { file }).length > 0);
|
|
62
|
+
}
|
|
55
63
|
export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
|
|
56
64
|
const keys = { ...(readState(file).providerKeys || {}) };
|
|
57
65
|
const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
@@ -72,18 +80,6 @@ export function removeProviderKeys(id, targets = [], opts = {}) {
|
|
|
72
80
|
const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
|
|
73
81
|
return saveProviderKeys(id, list, opts);
|
|
74
82
|
}
|
|
75
|
-
export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
|
|
76
|
-
const env = process.env[_providerShareEnv(id)] || "";
|
|
77
|
-
if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
|
|
78
|
-
const map = readState(file).providerShareKeys;
|
|
79
|
-
return !!(map && typeof map === "object" && map[id]);
|
|
80
|
-
}
|
|
81
|
-
export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
|
|
82
|
-
const map = { ...(readState(file).providerShareKeys || {}) };
|
|
83
|
-
if (on) map[id] = true; else delete map[id];
|
|
84
|
-
writeStateImmediate(file, { providerShareKeys: map });
|
|
85
|
-
return !!on;
|
|
86
|
-
}
|
|
87
83
|
export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
|
|
88
84
|
const v = readState(file).providerConfigs;
|
|
89
85
|
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
package/src/state/store.js
CHANGED
|
@@ -8,7 +8,7 @@ import { mergeState, COLD_WINS } from "./merge.js";
|
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_PORT = 8989;
|
|
10
10
|
|
|
11
|
-
function isTestEnv() {
|
|
11
|
+
export function isTestEnv() {
|
|
12
12
|
if (process.env.NODE_ENV === "test") return true;
|
|
13
13
|
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
14
14
|
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|