mslxdff 0.1.93 → 0.1.94
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/cli/commands/sync.js +57 -6
- package/src/cli/help.js +1 -0
- package/src/responses/translate.js +220 -0
- package/src/routes/index.js +7 -0
- package/src/routes/models-route.js +16 -3
- package/src/routes/responses-route.js +162 -0
- package/src/sync-codex.js +116 -0
- package/src/sync-opencode.js +22 -6
- package/src/sync-workbuddy.js +24 -2
package/package.json
CHANGED
package/src/cli/commands/sync.js
CHANGED
|
@@ -4,18 +4,65 @@ import { getPreferredModel as getPref } from "../../auto.js";
|
|
|
4
4
|
import { normalizeModel } from "../../reasoning.js";
|
|
5
5
|
import { syncToWorkbuddy, workbuddyModelsPath } from "../../sync-workbuddy.js";
|
|
6
6
|
import { syncToOpencode, opencodeConfigPath } from "../../sync-opencode.js";
|
|
7
|
+
import { syncToCodex, codexConfigPath } from "../../sync-codex.js";
|
|
7
8
|
import { createModelsService } from "../../models.js";
|
|
8
9
|
import { createUpstreamClient } from "../../upstream.js";
|
|
9
10
|
import { logDir } from "../../logs.js";
|
|
10
11
|
|
|
12
|
+
// 剪枝口径:picks 非空时,未在 picks 的旧模型视为失效,下次 setto 从第三方配置里摘除;
|
|
13
|
+
// picks 为空(=不筛选)时返回 null,sync 侧一个不动。
|
|
14
|
+
function pruneKeep() {
|
|
15
|
+
const picks = loadModelPicks();
|
|
16
|
+
return picks.length ? picks : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export async function handleSetto(args) {
|
|
12
20
|
if (!(args.includes("-setto") || args.includes("--setto"))) return false;
|
|
13
21
|
const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
|
|
14
22
|
const target = args[idx + 1];
|
|
15
|
-
if (!["workbuddy", "opencode"].includes(target)) {
|
|
16
|
-
console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId|--all]");
|
|
23
|
+
if (!["workbuddy", "opencode", "chatgpt", "codex"].includes(target)) {
|
|
24
|
+
console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId|--all] | mslxdff -setto chatgpt [modelId]");
|
|
17
25
|
process.exit(1);
|
|
18
26
|
}
|
|
27
|
+
if (target === "chatgpt" || target === "codex") {
|
|
28
|
+
// Codex/ChatGPT 三端共用 ~/.codex/config.toml:写 model + model_provider + [model_providers.mslxdff]
|
|
29
|
+
const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
|
|
30
|
+
let id;
|
|
31
|
+
if (raw) {
|
|
32
|
+
if (raw === "auto" || !raw) {
|
|
33
|
+
console.error("modelId 不能为 auto 或空");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const norm = normalizeModel(raw);
|
|
37
|
+
if (!norm) {
|
|
38
|
+
console.error("modelId 不能为空");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
savePreferredModel(norm);
|
|
42
|
+
console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
|
|
43
|
+
id = norm;
|
|
44
|
+
} else {
|
|
45
|
+
id = loadPreferredModel() || getPref();
|
|
46
|
+
if (!id) {
|
|
47
|
+
console.error("no preferred model set; use: mslxdff -setto chatgpt <modelId>");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const persisted = getPort();
|
|
53
|
+
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
54
|
+
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
55
|
+
const file = codexConfigPath();
|
|
56
|
+
const r = syncToCodex({ id, port, file });
|
|
57
|
+
console.log(`synced to codex: ${r.action} "${r.id}" @ ${r.file}`);
|
|
58
|
+
console.log(` url: http://127.0.0.1:${port}/v1/responses (Responses API)`);
|
|
59
|
+
console.log(` 鉴权走 mslxdff -showtoken 命令(token 不落盘),直接 codex exec "hi" 验证`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`failed to sync to codex: ${String(err?.message || err)}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
19
66
|
if (target === "opencode") {
|
|
20
67
|
const wantsAll = args.includes("--all") || args.includes("-a") || args[idx + 2] === "all";
|
|
21
68
|
if (wantsAll) {
|
|
@@ -31,17 +78,19 @@ export async function handleSetto(args) {
|
|
|
31
78
|
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
32
79
|
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
33
80
|
const file = opencodeConfigPath();
|
|
34
|
-
let inserted = 0, updated = 0;
|
|
81
|
+
let inserted = 0, updated = 0, prunedTotal = 0;
|
|
35
82
|
for (const rawId of list) {
|
|
36
83
|
const norm = normalizeModel(rawId);
|
|
37
84
|
if (!norm || norm === "auto") continue;
|
|
38
85
|
// 首次循环也同步 preferred(保持 daemon 热重载语义)
|
|
39
86
|
if (rawId === list[0]) savePreferredModel(norm);
|
|
40
|
-
const r = await syncToOpencode({ id: norm, token, port, file });
|
|
87
|
+
const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep() });
|
|
41
88
|
if (r.action === "inserted") inserted++; else updated++;
|
|
89
|
+
prunedTotal += r.pruned || 0;
|
|
42
90
|
console.log(` ${r.action} "${r.id}" -> ${r.internal} @ ${file}`);
|
|
43
91
|
}
|
|
44
92
|
console.log(`synced to opencode: ${inserted} inserted, ${updated} updated, total ${list.length} @ ${file}`);
|
|
93
|
+
if (prunedTotal) console.log(` pruned ${prunedTotal} 个失效模型(未在 picks,不再于 opencode 显示)`);
|
|
45
94
|
console.log(` url: http://127.0.0.1:${port}/v1`);
|
|
46
95
|
console.log(` models: ${list.map((x) => normalizeModel(x)).join(", ")}`);
|
|
47
96
|
console.log(` opencode 选 mslxdff/<model> 直达本地,同名如 mslxdff/deepseek-v4-flash-free 或 mslxdff/bai-deepseek-v4-flash`);
|
|
@@ -107,8 +156,9 @@ export async function handleSetto(args) {
|
|
|
107
156
|
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
108
157
|
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
109
158
|
const file = opencodeConfigPath();
|
|
110
|
-
const r = await syncToOpencode({ id, token, port, file });
|
|
159
|
+
const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep() });
|
|
111
160
|
console.log(`synced to opencode: ${r.action} "${r.id}" @ ${file}`);
|
|
161
|
+
if (r.pruned) console.log(` pruned ${r.pruned} 个失效模型(未在 picks,不再于 opencode 显示)`);
|
|
112
162
|
console.log(` url: http://127.0.0.1:${port}/v1`);
|
|
113
163
|
console.log(` opencode 选 mslxdff/${r.id} 直达本地 ${r.internal}${r.storageKey !== r.internal ? ` (dash→${r.internal} 自动映射)` : ""}`);
|
|
114
164
|
} catch (err) {
|
|
@@ -164,8 +214,9 @@ export async function handleSetto(args) {
|
|
|
164
214
|
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
165
215
|
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
166
216
|
const file = workbuddyModelsPath();
|
|
167
|
-
const r = await syncToWorkbuddy({ id, token, port, file });
|
|
217
|
+
const r = await syncToWorkbuddy({ id, token, port, file, keep: pruneKeep() });
|
|
168
218
|
console.log(`synced to WorkBuddy: ${r.action} "${id}" @ ${file}`);
|
|
219
|
+
if (r.pruned) console.log(` pruned ${r.pruned} 个失效模型(未在 picks,不再于 WorkBuddy 显示)`);
|
|
169
220
|
console.log(` url: http://127.0.0.1:${port}/v1/chat/completions`);
|
|
170
221
|
} catch (err) {
|
|
171
222
|
console.error(`failed to sync to WorkBuddy: ${String(err?.message || err)}`);
|
package/src/cli/help.js
CHANGED
|
@@ -21,6 +21,7 @@ Usage:
|
|
|
21
21
|
mslxdff -showtoken print the current auth token
|
|
22
22
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
23
23
|
mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
|
|
24
|
+
mslxdff -setto chatgpt [modelId] set default model and sync to Codex/ChatGPT ~/.codex/config.toml (model_providers.mslxdff → 127.0.0.1/v1/responses, auth via mslxdff -showtoken)
|
|
24
25
|
mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] add a generic OpenAI-compatible provider (myapi/gpt-4, baseUrl https://api.example.com/v1; extra models = allowlist)
|
|
25
26
|
mslxdff -provider add workbuddy https://copilot.tencent.com <key> [allow...] add WorkBuddy专用供应商(workbuddy/hy3 前缀路由,auths 与 keys 一一对应)
|
|
26
27
|
mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url] configure provider API keys/URL (multiple keys = rotating, set-url for generic)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responses ⇄ Chat 翻译层(给 Codex/ChatGPT 桌面端用的 /v1/responses 端点)。
|
|
3
|
+
* 纯函数,无网络、无副作用。无状态:previous_response_id 不支持(stateless 网关)。
|
|
4
|
+
*/
|
|
5
|
+
let seq = 0;
|
|
6
|
+
export function newResponseId() {
|
|
7
|
+
return `resp_${Date.now().toString(36)}${(seq++).toString(36)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 上游块可能是 Buffer/Uint8Array:String() 会变成 "100,97,..." 数字串,必须解码
|
|
11
|
+
const _decoder = new TextDecoder();
|
|
12
|
+
export function chunkToString(c) {
|
|
13
|
+
if (c == null) return "";
|
|
14
|
+
if (typeof c === "string") return c;
|
|
15
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(c)) return c.toString("utf8");
|
|
16
|
+
if (c instanceof Uint8Array) return _decoder.decode(c);
|
|
17
|
+
return String(c);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inputTextOf(content) {
|
|
21
|
+
if (typeof content === "string") return content;
|
|
22
|
+
if (Array.isArray(content)) {
|
|
23
|
+
return content.filter((p) => p && (p.type === "input_text" || p.type === "text")).map((p) => p.text || "").join("");
|
|
24
|
+
}
|
|
25
|
+
return String(content ?? "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// POST /v1/responses body → chat completions body(直接喂现有 pipeline)
|
|
29
|
+
export function responsesToChatBody(req = {}) {
|
|
30
|
+
const model = String(req.model || "").trim();
|
|
31
|
+
if (!model) throw new Error("responses: 缺少 model");
|
|
32
|
+
const messages = [];
|
|
33
|
+
if (req.instructions) messages.push({ role: "system", content: String(req.instructions) });
|
|
34
|
+
const input = req.input;
|
|
35
|
+
const items = typeof input === "string" ? [{ type: "message", role: "user", content: input }] : Array.isArray(input) ? input : [];
|
|
36
|
+
for (const it of items) {
|
|
37
|
+
if (!it || typeof it !== "object") continue;
|
|
38
|
+
if (it.type === "message") {
|
|
39
|
+
messages.push({ role: it.role || "user", content: inputTextOf(it.content) });
|
|
40
|
+
} else if (it.type === "function_call") {
|
|
41
|
+
messages.push({
|
|
42
|
+
role: "assistant", content: "",
|
|
43
|
+
tool_calls: [{ id: it.call_id || it.id || "", type: "function", function: { name: it.name || "", arguments: it.arguments || "" } }],
|
|
44
|
+
});
|
|
45
|
+
} else if (it.type === "function_call_output") {
|
|
46
|
+
messages.push({ role: "tool", tool_call_id: it.call_id || "", content: typeof it.output === "string" ? it.output : JSON.stringify(it.output ?? "") });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!messages.length) messages.push({ role: "user", content: "hi" });
|
|
50
|
+
const body = { model, messages, stream: Boolean(req.stream) };
|
|
51
|
+
if (Array.isArray(req.tools) && req.tools.length) {
|
|
52
|
+
body.tools = req.tools.map((t) => (t?.type === "function"
|
|
53
|
+
? { type: "function", function: { name: t.name, description: t.description || "", parameters: t.parameters || {} } }
|
|
54
|
+
: t));
|
|
55
|
+
}
|
|
56
|
+
if (req.tool_choice) {
|
|
57
|
+
const tc = req.tool_choice;
|
|
58
|
+
body.tool_choice = tc?.type === "function" ? { type: "function", function: { name: tc.name } } : tc;
|
|
59
|
+
}
|
|
60
|
+
if (req.max_output_tokens != null) body.max_tokens = Number(req.max_output_tokens);
|
|
61
|
+
if (req.temperature != null) body.temperature = req.temperature;
|
|
62
|
+
if (req.top_p != null) body.top_p = req.top_p;
|
|
63
|
+
if (req.parallel_tool_calls != null) body.parallel_tool_calls = req.parallel_tool_calls;
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// chat 口径 usage → Responses 口径(codex 解 ResponseCompleted 硬要 input_tokens/output_tokens,缺则整轮作废)
|
|
68
|
+
export function toResponsesUsage(u = {}) {
|
|
69
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
70
|
+
const src = u && typeof u === "object" ? u : {};
|
|
71
|
+
const pt = num(src.prompt_tokens ?? src.input_tokens);
|
|
72
|
+
const ct = num(src.completion_tokens ?? src.output_tokens);
|
|
73
|
+
return {
|
|
74
|
+
input_tokens: pt,
|
|
75
|
+
input_tokens_details: { cached_tokens: num(src.input_tokens_details?.cached_tokens ?? src.prompt_tokens_details?.cached_tokens) },
|
|
76
|
+
output_tokens: ct,
|
|
77
|
+
output_tokens_details: { reasoning_tokens: num(src.output_tokens_details?.reasoning_tokens ?? src.completion_tokens_details?.reasoning_tokens) },
|
|
78
|
+
total_tokens: num(src.total_tokens ?? pt + ct),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function messageToOutputItems(message = {}) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const text = typeof message.content === "string" ? message.content : "";
|
|
85
|
+
if (text) {
|
|
86
|
+
out.push({ type: "message", id: `msg_${newResponseId()}`, status: "completed", role: "assistant", content: [{ type: "output_text", text, annotations: [] }] });
|
|
87
|
+
}
|
|
88
|
+
for (const tc of message.tool_calls || []) {
|
|
89
|
+
out.push({ type: "function_call", id: `fc_${newResponseId()}`, call_id: tc.id || "", name: tc.function?.name || "", arguments: tc.function?.arguments || "", status: "completed" });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// chat JSON → Response 对象(非流式)
|
|
95
|
+
export function chatJsonToResponse(chatJson = {}, model = "") {
|
|
96
|
+
const choice = chatJson.choices?.[0] || {};
|
|
97
|
+
const output = messageToOutputItems(choice.message || {});
|
|
98
|
+
return {
|
|
99
|
+
id: chatJson.id && String(chatJson.id).startsWith("resp_") ? chatJson.id : newResponseId(),
|
|
100
|
+
object: "response",
|
|
101
|
+
created_at: chatJson.created || Math.floor(Date.now() / 1000),
|
|
102
|
+
status: choice.finish_reason === "length" ? "incomplete" : "completed",
|
|
103
|
+
model: model || chatJson.model || "",
|
|
104
|
+
output,
|
|
105
|
+
usage: toResponsesUsage(chatJson.usage),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// chat SSE chunk 流 → responses SSE 事件流(逐块翻译,tool_calls 按 index 累积)
|
|
110
|
+
export function createChunkTranslator(model = "") {
|
|
111
|
+
const id = newResponseId();
|
|
112
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
113
|
+
let buf = "";
|
|
114
|
+
let textItemOpen = false;
|
|
115
|
+
let textLen = 0;
|
|
116
|
+
let lastFinish = "stop";
|
|
117
|
+
let lastUsage = null;
|
|
118
|
+
const tools = new Map(); // index → {id, name, args, announced}
|
|
119
|
+
const ev = (type, extra = {}) => ({ type, ...extra });
|
|
120
|
+
|
|
121
|
+
function begin() {
|
|
122
|
+
return [ev("response.created", { response: { id, object: "response", created_at: createdAt, status: "in_progress", model, output: [] } })];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureTextItem() {
|
|
126
|
+
if (textItemOpen) return [];
|
|
127
|
+
textItemOpen = true;
|
|
128
|
+
const item = { type: "message", id: `msg_${id}`, status: "in_progress", role: "assistant", content: [{ type: "output_text", text: "", annotations: [] }] };
|
|
129
|
+
return [ev("response.output_item.added", { output_index: 0, item }), ev("response.content_part.added", { item_id: item.id, output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } })];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let fullText = "";
|
|
133
|
+
function pushText(delta) {
|
|
134
|
+
if (!delta) return [];
|
|
135
|
+
const out = ensureTextItem();
|
|
136
|
+
textLen += delta.length;
|
|
137
|
+
fullText += delta;
|
|
138
|
+
out.push(ev("response.output_text.delta", { item_id: `msg_${id}`, output_index: 0, content_index: 0, delta }));
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pushTool(tc) {
|
|
143
|
+
const idx = Number(tc.index ?? 0);
|
|
144
|
+
let t = tools.get(idx);
|
|
145
|
+
if (!t) { t = { id: "", name: "", args: "", announced: false }; tools.set(idx, t); }
|
|
146
|
+
if (tc.id) t.id = tc.id;
|
|
147
|
+
if (tc.function?.name) t.name += tc.function.name;
|
|
148
|
+
const frag = tc.function?.arguments || "";
|
|
149
|
+
const out = [];
|
|
150
|
+
if (!t.announced && t.id && t.name) {
|
|
151
|
+
t.announced = true;
|
|
152
|
+
out.push(ev("response.output_item.added", { output_index: idx + 1, item: { type: "function_call", id: `fc_${id}_${idx}`, call_id: t.id, name: t.name, arguments: "" } }));
|
|
153
|
+
}
|
|
154
|
+
if (frag) {
|
|
155
|
+
if (!t.announced) { t.args += frag; return out; } // id/name 未到先攒着
|
|
156
|
+
t.args += frag;
|
|
157
|
+
out.push(ev("response.function_call_arguments.delta", { item_id: `fc_${id}_${idx}`, output_index: idx + 1, delta: frag }));
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const dbg = { chunks: 0, textChars: 0, toolDeltas: 0, skippedLines: 0, jsonFails: 0, reasoningChars: 0 };
|
|
163
|
+
function push(data) {
|
|
164
|
+
buf += chunkToString(data);
|
|
165
|
+
const out = [];
|
|
166
|
+
let nl;
|
|
167
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
168
|
+
const line = buf.slice(0, nl).trim();
|
|
169
|
+
buf = buf.slice(nl + 1);
|
|
170
|
+
if (!line || line.startsWith(":")) continue;
|
|
171
|
+
if (!line.startsWith("data:")) { dbg.skippedLines++; continue; }
|
|
172
|
+
const payload = line.slice(5).trim();
|
|
173
|
+
if (payload === "[DONE]") continue;
|
|
174
|
+
let chunk;
|
|
175
|
+
try { chunk = JSON.parse(payload); } catch { dbg.jsonFails++; continue; }
|
|
176
|
+
dbg.chunks++;
|
|
177
|
+
const delta = chunk.choices?.[0]?.delta || {};
|
|
178
|
+
const fr = chunk.choices?.[0]?.finish_reason;
|
|
179
|
+
if (fr) lastFinish = fr;
|
|
180
|
+
if (chunk.usage) lastUsage = chunk.usage;
|
|
181
|
+
if (typeof delta.content === "string" && delta.content) { dbg.textChars += delta.content.length; out.push(...pushText(delta.content)); }
|
|
182
|
+
for (const tc of delta.tool_calls || []) { dbg.toolDeltas++; out.push(...pushTool(tc)); }
|
|
183
|
+
const rc = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
|
|
184
|
+
if (rc) { dbg.reasoningChars += rc.length; out.push(...pushText(rc)); }
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function end({ finish = "stop", usage = null } = {}) {
|
|
190
|
+
const out = [];
|
|
191
|
+
if (textItemOpen) {
|
|
192
|
+
out.push(ev("response.output_text.done", { item_id: `msg_${id}`, output_index: 0, content_index: 0, text: fullText }));
|
|
193
|
+
out.push(ev("response.content_part.done", { item_id: `msg_${id}`, output_index: 0, content_index: 0, part: { type: "output_text", text: fullText, annotations: [] } }));
|
|
194
|
+
out.push(ev("response.output_item.done", { output_index: 0, item: { type: "message", id: `msg_${id}`, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText, annotations: [] }] } }));
|
|
195
|
+
}
|
|
196
|
+
let i = 0;
|
|
197
|
+
for (const [idx, t] of tools) {
|
|
198
|
+
if (!t.id && !t.name && !t.args) { i++; continue; }
|
|
199
|
+
if (!t.announced) {
|
|
200
|
+
out.push(ev("response.output_item.added", { output_index: idx + 1, item: { type: "function_call", id: `fc_${id}_${idx}`, call_id: t.id, name: t.name, arguments: "" } }));
|
|
201
|
+
}
|
|
202
|
+
if (t.args) out.push(ev("response.function_call_arguments.done", { item_id: `fc_${id}_${idx}`, output_index: idx + 1, arguments: t.args }));
|
|
203
|
+
out.push(ev("response.output_item.done", { output_index: idx + 1, item: { type: "function_call", id: `fc_${id}_${idx}`, call_id: t.id, name: t.name, arguments: t.args, status: "completed" } }));
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
void i; void textLen;
|
|
207
|
+
out.push(ev("response.completed", { response: { id, object: "response", created_at: createdAt, status: finish === "length" ? "incomplete" : "completed", model, output: [], usage: toResponsesUsage(usage) } }));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getFinal() {
|
|
212
|
+
return { finish: lastFinish, usage: lastUsage };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stats() {
|
|
216
|
+
return { ...dbg, outEvents: null, textItemOpen, toolCalls: tools.size };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { id, begin, push, end, getFinal, stats };
|
|
220
|
+
}
|
package/src/routes/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { joinHandler, leaveHandler } from "./groups.js";
|
|
|
6
6
|
import { heartbeatHandler, pollHandler, resultHandler, forwardHandler, streamHandler } from "./groups-relay.js";
|
|
7
7
|
import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
|
|
8
8
|
import { relayHandler } from "./relay.js";
|
|
9
|
+
import { responsesHandler } from "./responses-route.js";
|
|
9
10
|
|
|
10
11
|
export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus, plugins }) {
|
|
11
12
|
return async function router(req, res) {
|
|
@@ -43,6 +44,12 @@ const ROUTES = [
|
|
|
43
44
|
requiresAuth: true,
|
|
44
45
|
handler: chatHandler,
|
|
45
46
|
},
|
|
47
|
+
{
|
|
48
|
+
method: "POST",
|
|
49
|
+
path: "/v1/responses",
|
|
50
|
+
requiresAuth: true,
|
|
51
|
+
handler: responsesHandler,
|
|
52
|
+
},
|
|
46
53
|
{
|
|
47
54
|
method: "POST",
|
|
48
55
|
path: "/v1/groups/join",
|
|
@@ -2,8 +2,21 @@ import { json, errMsg } from "./helpers.js";
|
|
|
2
2
|
import { runHook } from "../plugins.js";
|
|
3
3
|
import { isModelAllowed } from "../state.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Codex 自定义 provider 拉目录要顶层 `models` 数组(codex-rs endpoint/models.rs 解 ModelsResponse{models}),
|
|
6
|
+
// 给它 OpenAI 标准 {object,data} 会报 missing field `models`。学 OmniRoute:仅 codex 调用者追加空数组
|
|
7
|
+
// (填真目录反而会覆盖 codex 内置 agent prompt,必须空),其他客户端保持字节一致。
|
|
8
|
+
export function isCodexModelsCaller(req) {
|
|
9
|
+
const h = req?.headers || {};
|
|
10
|
+
if (/^codex_/i.test(String(h["user-agent"] || ""))) return true;
|
|
11
|
+
if (/^codex_/i.test(String(h.originator || ""))) return true;
|
|
12
|
+
const q = String(req?.url || "").split("?")[1] || "";
|
|
13
|
+
return /(^|&)client_version=/.test(q);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function modelsHandler({ req, res, models, plugins }) {
|
|
6
17
|
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
18
|
+
const codex = isCodexModelsCaller(req);
|
|
19
|
+
const withCodex = (out) => (codex && out && typeof out === "object" ? { ...out, models: [] } : out);
|
|
7
20
|
try {
|
|
8
21
|
let data = await models.get();
|
|
9
22
|
// 插件 hook:models:list — 返回数组可替换对外模型列表({object:"list",data:[...]} 或纯 id 数组)
|
|
@@ -15,10 +28,10 @@ export async function modelsHandler({ res, models, plugins }) {
|
|
|
15
28
|
const out = idsOnly
|
|
16
29
|
? { object: "list", data: ml.value.map((id) => ({ id, object: "model", owned_by: "plugin" })) }
|
|
17
30
|
: { object: "list", data: ml.value };
|
|
18
|
-
return json(res, 200, out);
|
|
31
|
+
return json(res, 200, withCodex(out));
|
|
19
32
|
}
|
|
20
33
|
}
|
|
21
|
-
json(res, 200, data);
|
|
34
|
+
json(res, 200, withCodex(data));
|
|
22
35
|
} catch (err) {
|
|
23
36
|
json(res, 502, { error: errMsg(err) });
|
|
24
37
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { json, readBody } from "./helpers.js";
|
|
2
|
+
import { createChatPipeline } from "../chat-pipeline/index.js";
|
|
3
|
+
import { responsesToChatBody, chatJsonToResponse, createChunkTranslator, chunkToString } from "../responses/translate.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /v1/responses — 给 Codex/ChatGPT 桌面端用的 Responses API 薄壳。
|
|
7
|
+
* 复用 ChatPipeline 全链路(auto/hedge/failover/tool_calls),只做形状翻译。
|
|
8
|
+
* 非流式:收集 chat JSON → 转 Response 对象;流式:逐块实时翻成 responses SSE。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function withBody(req, body) {
|
|
12
|
+
return Object.assign(Object.create(Object.getPrototypeOf(req)), req, { body });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function errorShape(text, status) {
|
|
16
|
+
let msg = String(text || "").slice(0, 500);
|
|
17
|
+
try {
|
|
18
|
+
const j = JSON.parse(String(text || ""));
|
|
19
|
+
msg = String(j?.error?.message || j?.error || msg);
|
|
20
|
+
} catch {}
|
|
21
|
+
return { error: { message: msg || "upstream error", type: "server_error", code: status } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 最小事件发射器:pipeline 靠 res.on("close") 感知下游断开,垫片必须有
|
|
25
|
+
function createEmitter() {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
const self = {
|
|
28
|
+
on(ev, fn) { if (typeof fn === "function") { if (!map.has(ev)) map.set(ev, []); map.get(ev).push(fn); } return self; },
|
|
29
|
+
once(ev, fn) { const w = (...a) => { self.off(ev, w); fn(...a); }; return self.on(ev, w); },
|
|
30
|
+
off(ev, fn) { const l = map.get(ev); if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1); } return self; },
|
|
31
|
+
removeListener(ev, fn) { return self.off(ev, fn); },
|
|
32
|
+
emit(ev, ...a) { for (const fn of [...(map.get(ev) || [])]) { try { fn(...a); } catch {} } return true; },
|
|
33
|
+
};
|
|
34
|
+
return self;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 非流式:收集捕获
|
|
38
|
+
export function createCollector() {
|
|
39
|
+
let status = 200;
|
|
40
|
+
let text = "";
|
|
41
|
+
const res = {
|
|
42
|
+
...createEmitter(),
|
|
43
|
+
set statusCode(v) { status = v; },
|
|
44
|
+
get statusCode() { return status; },
|
|
45
|
+
headersSent: false,
|
|
46
|
+
setHeader() {},
|
|
47
|
+
write(c) { text += chunkToString(c); return true; },
|
|
48
|
+
end(c) { if (c != null) text += chunkToString(c); res.headersSent = true; },
|
|
49
|
+
};
|
|
50
|
+
return { res, get: () => ({ status, text }) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 流式:逐 write 实时翻译并转发(head 延迟到首块,避免错误状态码已发送)
|
|
54
|
+
export function createLiveForwarder(realRes, translator) {
|
|
55
|
+
let status = 200;
|
|
56
|
+
let headSent = false;
|
|
57
|
+
let ended = false;
|
|
58
|
+
const sendHead = () => {
|
|
59
|
+
if (headSent) return;
|
|
60
|
+
headSent = true;
|
|
61
|
+
realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
62
|
+
for (const e of translator.begin()) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
63
|
+
};
|
|
64
|
+
const res = {
|
|
65
|
+
...createEmitter(),
|
|
66
|
+
set statusCode(v) { status = v; },
|
|
67
|
+
get statusCode() { return status; },
|
|
68
|
+
headersSent: false,
|
|
69
|
+
setHeader() {},
|
|
70
|
+
write(c) {
|
|
71
|
+
if (ended) return true;
|
|
72
|
+
sendHead();
|
|
73
|
+
for (const e of translator.push(chunkToString(c))) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
end(c) {
|
|
77
|
+
if (ended) return;
|
|
78
|
+
ended = true;
|
|
79
|
+
res.headersSent = true;
|
|
80
|
+
if (status >= 400 && !headSent) {
|
|
81
|
+
json(realRes, status, errorShape(c, status));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
sendHead();
|
|
85
|
+
if (c != null) for (const e of translator.push(chunkToString(c))) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
86
|
+
const fin = translator.getFinal();
|
|
87
|
+
for (const e of translator.end({ finish: fin.finish, usage: fin.usage })) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
88
|
+
realRes.write("data: [DONE]\n\n");
|
|
89
|
+
try { realRes.end(); } catch {}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return res;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const RDEBUG = process.env.MSLXDFF_RESPONSES_DEBUG === "1";
|
|
96
|
+
function rlog(...a) {
|
|
97
|
+
if (RDEBUG) console.log("[responses]", ...a);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function responsesHandler(ctx) {
|
|
101
|
+
const { req, res } = ctx;
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
let body;
|
|
104
|
+
try {
|
|
105
|
+
body = await readBody(req);
|
|
106
|
+
} catch {
|
|
107
|
+
return json(res, 400, { error: { message: "Invalid JSON body" } });
|
|
108
|
+
}
|
|
109
|
+
rlog("req", JSON.stringify({
|
|
110
|
+
model: body?.model, stream: body?.stream,
|
|
111
|
+
inputType: typeof body?.input, inputLen: Array.isArray(body?.input) ? body.input.length : String(body?.input || "").length,
|
|
112
|
+
inputKinds: Array.isArray(body?.input) ? [...new Set(body.input.map((i) => i?.type))] : null,
|
|
113
|
+
tools: Array.isArray(body?.tools) ? body.tools.length : 0,
|
|
114
|
+
instructionsLen: String(body?.instructions || "").length,
|
|
115
|
+
}));
|
|
116
|
+
let chatBody;
|
|
117
|
+
try {
|
|
118
|
+
chatBody = responsesToChatBody(body);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
rlog("translate-req-400", String(e?.message || e));
|
|
121
|
+
return json(res, 400, { error: { message: String(e?.message || e) } });
|
|
122
|
+
}
|
|
123
|
+
rlog("chat", JSON.stringify({ model: chatBody.model, stream: chatBody.stream, msgs: chatBody.messages.map((m) => `${m.role}:${String(m.content || "").length}${m.tool_calls ? `+${m.tool_calls.length}tc` : ""}`) }));
|
|
124
|
+
const pipeline = createChatPipeline(ctx);
|
|
125
|
+
const fakeReq = withBody(req, chatBody);
|
|
126
|
+
// 真连接断开 → 透传给垫片,pipeline 的 abort 链路不断
|
|
127
|
+
const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch {} };
|
|
128
|
+
try {
|
|
129
|
+
if (chatBody.stream) {
|
|
130
|
+
const translator = createChunkTranslator(chatBody.model);
|
|
131
|
+
const live = createLiveForwarder(res, translator);
|
|
132
|
+
forwardClose(live);
|
|
133
|
+
await pipeline.execute({ req: fakeReq, res: live });
|
|
134
|
+
const st = translator.stats();
|
|
135
|
+
rlog("done-stream", JSON.stringify({ ms: Date.now() - t0, model: chatBody.model, ...st, finish: translator.getFinal().finish, usage: translator.getFinal().usage }));
|
|
136
|
+
} else {
|
|
137
|
+
const cap = createCollector();
|
|
138
|
+
forwardClose(cap.res);
|
|
139
|
+
await pipeline.execute({ req: fakeReq, res: cap.res });
|
|
140
|
+
const { status, text } = cap.get();
|
|
141
|
+
rlog("done-json", JSON.stringify({ ms: Date.now() - t0, model: chatBody.model, status, bytes: text.length, head: text.slice(0, 160) }));
|
|
142
|
+
if (status >= 400) return json(res, status, errorShape(text, status));
|
|
143
|
+
let chatJson = null;
|
|
144
|
+
try { chatJson = JSON.parse(text); } catch {}
|
|
145
|
+
if (!chatJson || chatJson.object === "error" || chatJson.error) {
|
|
146
|
+
rlog("translate-resp-502", text.slice(0, 300));
|
|
147
|
+
return json(res, status >= 400 ? status : 502, errorShape(text, status));
|
|
148
|
+
}
|
|
149
|
+
const respObj = chatJsonToResponse(chatJson, chatBody.model);
|
|
150
|
+
rlog("done-resp", JSON.stringify({ ms: Date.now() - t0, model: chatBody.model, status: respObj.status, items: respObj.output.length, textLen: JSON.stringify(respObj.output).length, usage: respObj.usage }));
|
|
151
|
+
// 非 JSON 透传(上游偶发):包成纯文本 Response
|
|
152
|
+
if (!chatJson.choices) {
|
|
153
|
+
return json(res, 200, { id: chatJson.id || `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "completed", model: chatBody.model, output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: String(text).slice(0, 8000) }] }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } });
|
|
154
|
+
}
|
|
155
|
+
return json(res, 200, respObj);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
rlog("execute-throw", String(err?.message || err).slice(0, 300));
|
|
159
|
+
if (!res.headersSent) return json(res, 502, { error: { message: String(err?.message || err).slice(0, 500) } });
|
|
160
|
+
try { res.end(); } catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// TOML 字符串:含反斜杠(Windows 路径)时用字面单引号,避免转义被吃
|
|
6
|
+
function tomlStr(s) {
|
|
7
|
+
const v = String(s);
|
|
8
|
+
if (v.includes("\\") && !v.includes("'")) return `'${v}'`;
|
|
9
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// auth 命令解析:Codex 起子进程不保证继承终端 PATH,所以不用裸 `mslxdff`,
|
|
13
|
+
// 而用绝对路径 node + 绝对路径脚本(当前进程即 mslxdff 本体,最可信)。
|
|
14
|
+
export function buildAuthCommand({ execPath = process.execPath, argv1 = process.argv[1], cwd = process.cwd() } = {}) {
|
|
15
|
+
const script = argv1 ? (isAbsolute(argv1) ? argv1 : resolve(cwd, argv1)) : "";
|
|
16
|
+
if (execPath && script && /mslxdff(\.js)?$/i.test(script)) {
|
|
17
|
+
try { if (existsSync(script)) return { command: execPath, args: [script, "-showtoken"] }; } catch {}
|
|
18
|
+
}
|
|
19
|
+
return { command: "mslxdff", args: ["-showtoken"] }; // 兜底:走 PATH
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Codex/ChatGPT 三端共用 ~/.codex/config.toml:自定义 provider 指向本地网关。
|
|
23
|
+
export function codexConfigPath() {
|
|
24
|
+
const home = process.env.CODEX_HOME;
|
|
25
|
+
if (typeof home === "string" && home.trim()) return join(home.trim(), "config.toml");
|
|
26
|
+
return join(os.homedir(), ".codex", "config.toml");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 顶层 key = "value" 行:有则替换,无则追加
|
|
30
|
+
function upsertTopKey(lines, key, value) {
|
|
31
|
+
const re = new RegExp(`^\\s*${key}\\s*=`);
|
|
32
|
+
const line = `${key} = "${value}"`;
|
|
33
|
+
const idx = lines.findIndex((l) => re.test(l) && !l.trim().startsWith("#"));
|
|
34
|
+
if (idx >= 0) {
|
|
35
|
+
if (lines[idx].trim() === line) return { lines, changed: false };
|
|
36
|
+
lines[idx] = line;
|
|
37
|
+
return { lines, changed: true };
|
|
38
|
+
}
|
|
39
|
+
// 插到首个 [section] 之前(顶层区),无 section 则末尾追加
|
|
40
|
+
const secIdx = lines.findIndex((l) => /^\s*\[/.test(l));
|
|
41
|
+
if (secIdx >= 0) lines.splice(secIdx, 0, line);
|
|
42
|
+
else { if (lines.length && lines[lines.length - 1].trim()) lines.push(""); lines.push(line); }
|
|
43
|
+
return { lines, changed: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// [model_providers.mslxdff] 整段替换(段从表头到下个 ^[ 或 EOF),无则追加
|
|
47
|
+
function upsertProviderSection(lines, section) {
|
|
48
|
+
const head = "[model_providers.mslxdff]";
|
|
49
|
+
const authHead = "[model_providers.mslxdff.auth]";
|
|
50
|
+
let start = lines.findIndex((l) => l.trim() === head);
|
|
51
|
+
const main = [head, ...section.main];
|
|
52
|
+
const auth = ["[model_providers.mslxdff.auth]", ...section.auth];
|
|
53
|
+
const full = [...main, "", ...auth];
|
|
54
|
+
if (start >= 0) {
|
|
55
|
+
let end = lines.length;
|
|
56
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
57
|
+
const t = lines[i].trim();
|
|
58
|
+
// 自家子段([model_providers.mslxdff.*])不算边界,一并替换
|
|
59
|
+
if (/^\s*\[/.test(lines[i]) && !t.startsWith("[model_providers.mslxdff.")) { end = i; break; }
|
|
60
|
+
}
|
|
61
|
+
const old = lines.slice(start, end).join("\n").trim();
|
|
62
|
+
if (old === full.join("\n").trim()) return { lines, changed: false };
|
|
63
|
+
lines.splice(start, end - start, ...full);
|
|
64
|
+
return { lines, changed: true };
|
|
65
|
+
}
|
|
66
|
+
if (lines.length && lines[lines.length - 1].trim()) lines.push("");
|
|
67
|
+
lines.push(...full);
|
|
68
|
+
return { lines, changed: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildCodexProvider({ port, auth } = {}) {
|
|
72
|
+
const p = Number(port) || 8989;
|
|
73
|
+
const a = auth || buildAuthCommand();
|
|
74
|
+
return {
|
|
75
|
+
main: [
|
|
76
|
+
'name = "mslxdff local gateway"',
|
|
77
|
+
`base_url = "http://127.0.0.1:${p}/v1"`,
|
|
78
|
+
'wire_api = "responses"', // 显式锁定:现行 Codex 自定义 provider 只认 Responses
|
|
79
|
+
],
|
|
80
|
+
// command-backed auth:调 mslxdff -showtoken 取 Bearer,token 永不落盘
|
|
81
|
+
auth: [
|
|
82
|
+
`command = ${tomlStr(a.command)}`,
|
|
83
|
+
`args = [${a.args.map(tomlStr).join(", ")}]`,
|
|
84
|
+
"timeout_ms = 5000",
|
|
85
|
+
"refresh_interval_ms = 300000",
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function syncToCodex({ id, port, file, auth } = {}) {
|
|
91
|
+
const targetFile = file || codexConfigPath();
|
|
92
|
+
const model = String(id || "").trim();
|
|
93
|
+
if (!model) throw new Error("model id required");
|
|
94
|
+
let text = "";
|
|
95
|
+
try { text = readFileSync(targetFile, "utf8"); } catch { text = ""; }
|
|
96
|
+
const lines = text ? text.split("\n") : [];
|
|
97
|
+
let changed = false;
|
|
98
|
+
let r = upsertTopKey(lines, "model", model);
|
|
99
|
+
changed = changed || r.changed;
|
|
100
|
+
r = upsertTopKey(r.lines, "model_provider", "mslxdff");
|
|
101
|
+
changed = changed || r.changed;
|
|
102
|
+
r = upsertProviderSection(r.lines, buildCodexProvider({ port, auth }));
|
|
103
|
+
changed = changed || r.changed;
|
|
104
|
+
const next = r.lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "") + "\n";
|
|
105
|
+
if (!changed && text === next) return { action: "updated", file: targetFile, id: model };
|
|
106
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
107
|
+
const tmp = `${targetFile}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
108
|
+
writeFileSync(tmp, next, "utf8");
|
|
109
|
+
try {
|
|
110
|
+
renameSync(tmp, targetFile);
|
|
111
|
+
} catch {
|
|
112
|
+
try { writeFileSync(targetFile, next, "utf8"); } catch {}
|
|
113
|
+
}
|
|
114
|
+
try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
|
|
115
|
+
return { action: text ? "updated" : "inserted", file: targetFile, id: model };
|
|
116
|
+
}
|
package/src/sync-opencode.js
CHANGED
|
@@ -30,6 +30,12 @@ export function toStorageKey(canonical) {
|
|
|
30
30
|
return internal.includes("/") ? internal.replace(/\//g, "-") : internal;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// 剪枝比较键:legacy 前缀剥掉 + / → -(picks 里 slash 形态与存储 dash 形态互认)
|
|
34
|
+
export function normalizeOpencodeKey(k) {
|
|
35
|
+
const inner = toInternalId(String(k || ""));
|
|
36
|
+
return inner.includes("/") ? inner.replace(/\//g, "-") : inner;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
// 从存储键还原为内部 canonical(需查 alias 表,调用方用 getModelAlias)
|
|
34
40
|
export function storageKeyToCanonical(storageKey) {
|
|
35
41
|
const s = String(storageKey || "").trim();
|
|
@@ -61,7 +67,18 @@ export function isOpencodeLocalUrl(url) {
|
|
|
61
67
|
return u.includes("127.0.0.1") && u.includes("/v1");
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
// 剪枝:删掉不在 keep(picks 口径)里的旧模型键。keep 为空/非数组时不动(向后兼容)。
|
|
71
|
+
export function pruneOpencodeModels(models, keep, currentKey) {
|
|
72
|
+
if (!Array.isArray(keep) || !keep.length || !models || typeof models !== "object") return 0;
|
|
73
|
+
const keepSet = new Set([normalizeOpencodeKey(currentKey), ...keep.map(normalizeOpencodeKey)].filter(Boolean));
|
|
74
|
+
let pruned = 0;
|
|
75
|
+
for (const k of Object.keys(models)) {
|
|
76
|
+
if (!keepSet.has(normalizeOpencodeKey(k))) { delete models[k]; pruned++; }
|
|
77
|
+
}
|
|
78
|
+
return pruned;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function syncToOpencode({ id, token, port, file, keep } = {}) {
|
|
65
82
|
const targetFile = file || opencodeConfigPath();
|
|
66
83
|
const normalizedRaw = String(id || "").trim();
|
|
67
84
|
if (!normalizedRaw) throw new Error("model id required");
|
|
@@ -101,15 +118,13 @@ export async function syncToOpencode({ id, token, port, file } = {}) {
|
|
|
101
118
|
|
|
102
119
|
let action;
|
|
103
120
|
let effectiveId = storageKey;
|
|
121
|
+
let pruned = 0;
|
|
104
122
|
if (oldProvider) {
|
|
105
123
|
const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
|
|
106
124
|
? oldProvider.models
|
|
107
125
|
: {};
|
|
108
126
|
// 归一所有旧 key 到 storageKey 维度,判断是否已存在(兼容 mslxdff- 前缀与 / 形态)
|
|
109
|
-
const normalizeToStorage =
|
|
110
|
-
const inner = toInternalId(String(k));
|
|
111
|
-
return inner.includes("/") ? inner.replace(/\//g, "-") : inner;
|
|
112
|
-
};
|
|
127
|
+
const normalizeToStorage = normalizeOpencodeKey;
|
|
113
128
|
let existingKey = null;
|
|
114
129
|
for (const k of Object.keys(oldModels)) {
|
|
115
130
|
if (normalizeToStorage(k) === storageKey) { existingKey = k; break; }
|
|
@@ -143,6 +158,7 @@ export async function syncToOpencode({ id, token, port, file } = {}) {
|
|
|
143
158
|
effectiveId = storageKey;
|
|
144
159
|
action = "inserted";
|
|
145
160
|
}
|
|
161
|
+
pruned = pruneOpencodeModels(nextModels, keep, storageKey);
|
|
146
162
|
const nextProvider = {
|
|
147
163
|
...oldProvider,
|
|
148
164
|
name: oldProvider.name || "mslxdff",
|
|
@@ -192,5 +208,5 @@ export async function syncToOpencode({ id, token, port, file } = {}) {
|
|
|
192
208
|
}
|
|
193
209
|
} catch {}
|
|
194
210
|
|
|
195
|
-
return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey };
|
|
211
|
+
return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned };
|
|
196
212
|
}
|
package/src/sync-workbuddy.js
CHANGED
|
@@ -13,6 +13,27 @@ export function workbuddyModelsPath() {
|
|
|
13
13
|
// mslxdff 需保留原始 / 格式用于路由,所以同时存原始 id
|
|
14
14
|
const toWorkbuddyId = (id) => String(id).replace(/\//g, "-");
|
|
15
15
|
|
|
16
|
+
// 剪枝比较键:legacy 前缀剥掉 + / → -(picks 里 slash 形态与存储 dash 形态互认)
|
|
17
|
+
export function normalizeWorkbuddyKey(k) {
|
|
18
|
+
const s = String(k || "");
|
|
19
|
+
const inner = s.startsWith("mslxdff-") ? s.slice("mslxdff-".length) : s;
|
|
20
|
+
return inner.includes("/") ? inner.replace(/\//g, "-") : inner;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 剪枝:只删我们写的本地条目(127.0.0.1)中不在 keep 里的;非本地条目永不动
|
|
24
|
+
export function pruneWorkbuddyEntries(arr, keep, currentId) {
|
|
25
|
+
if (!Array.isArray(keep) || !keep.length || !Array.isArray(arr)) return 0;
|
|
26
|
+
const keepSet = new Set([normalizeWorkbuddyKey(currentId), ...keep.map(normalizeWorkbuddyKey)].filter(Boolean));
|
|
27
|
+
let pruned = 0;
|
|
28
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
29
|
+
const m = arr[i];
|
|
30
|
+
if (!isLocalUrl(m?.url)) continue;
|
|
31
|
+
const keys = [normalizeWorkbuddyKey(m.id), normalizeWorkbuddyKey(m._mslxdffOriginalId)];
|
|
32
|
+
if (!keys.some((k) => k && keepSet.has(k))) { arr.splice(i, 1); pruned++; }
|
|
33
|
+
}
|
|
34
|
+
return pruned;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
export function buildWorkbuddyEntry({ id, token, port }) {
|
|
17
38
|
const p = Number(port) || 8989;
|
|
18
39
|
const originalId = String(id);
|
|
@@ -46,7 +67,7 @@ function isTargetEntry(m, id) {
|
|
|
46
67
|
return false;
|
|
47
68
|
}
|
|
48
69
|
|
|
49
|
-
export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
70
|
+
export async function syncToWorkbuddy({ id, token, port, file, keep } = {}) {
|
|
50
71
|
const targetFile = file || workbuddyModelsPath();
|
|
51
72
|
const cleanId = String(id || "").trim();
|
|
52
73
|
if (!cleanId) throw new Error("model id required");
|
|
@@ -107,6 +128,7 @@ export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
|
107
128
|
arr.push(buildWorkbuddyEntry({ id: cleanId, token: cleanToken, port: p }));
|
|
108
129
|
action = "inserted";
|
|
109
130
|
}
|
|
131
|
+
const pruned = pruneWorkbuddyEntries(arr, keep, cleanId);
|
|
110
132
|
|
|
111
133
|
// atomic write
|
|
112
134
|
mkdirSync(dirname(targetFile), { recursive: true });
|
|
@@ -132,5 +154,5 @@ export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
|
132
154
|
persistModelAliases();
|
|
133
155
|
}
|
|
134
156
|
|
|
135
|
-
return { action, file: targetFile, id: cleanId, corrupted };
|
|
157
|
+
return { action, file: targetFile, id: cleanId, corrupted, pruned };
|
|
136
158
|
}
|