@omnicrawl/cli-linux-arm-musl 0.2.6 → 0.3.0
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/README.md +1 -1
- package/bin/omnicrawl +0 -0
- package/host/omnicrawl +0 -0
- package/host/omnicrawl-api +0 -0
- package/host/omnicrawl-decision +0 -0
- package/host/omnicrawl-host +0 -0
- package/host/omnicrawl-mcp-server +0 -0
- package/host/omnicrawl-tui +0 -0
- package/host/rust/assets/config-templates/config.example.toml +325 -0
- package/host/rust/assets/config-templates/decision_models.example.toml +162 -0
- package/host/rust/assets/config-templates/models.example.toml +71 -0
- package/host/rust/assets/config-templates/subagents.example.toml +38 -0
- package/host/rust/assets/extensions/node_runner.mjs +402 -0
- package/host/rust/assets/extensions/onejev_worker.py +293 -0
- package/host/rust/assets/templates/advisor_system.md +22 -0
- package/host/rust/assets/templates/model_reply_compaction_system.md +28 -0
- package/host/rust/assets/templates/plan.md +110 -0
- package/host/rust/assets/templates/subagents/explore.md +20 -0
- package/host/rust/assets/templates/subagents/general-purpose.md +42 -0
- package/host/rust/assets/templates/subagents/plan.md +20 -0
- package/host/rust/assets/templates/subagents/review.md +130 -0
- package/host/rust/assets/templates/subagents/verify.md +35 -0
- package/host/rust/assets/templates/summary_prompt.md +77 -0
- package/host/rust/assets/templates/system_prompt.md +76 -0
- package/host/rust/assets/templates/tool_call_summary_system.md +30 -0
- package/host/rust/assets/templates/tool_output_compression_system.md +28 -0
- package/package.json +10 -3
- package/version.json +4 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OmniCrawl 单插件 Node runner。
|
|
4
|
+
*
|
|
5
|
+
* Host 固定调用:
|
|
6
|
+
* node node_runner.mjs --plugin-root <canonical-path>
|
|
7
|
+
*
|
|
8
|
+
* 协议:stdin/stdout NDJSON JSON-RPC 2.0。
|
|
9
|
+
* 插件日志经 SDK logger 走 stderr 结构化行;普通 console 也被重定向到 stderr。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import readline from "node:readline";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const args = { pluginRoot: "" };
|
|
23
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
24
|
+
const item = argv[i];
|
|
25
|
+
if (item === "--plugin-root") {
|
|
26
|
+
args.pluginRoot = argv[i + 1] || "";
|
|
27
|
+
i += 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return args;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeStdout(message) {
|
|
34
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeStderrLine(level, message, fields = {}) {
|
|
38
|
+
const payload = {
|
|
39
|
+
level,
|
|
40
|
+
message: String(message),
|
|
41
|
+
fields,
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 强制把 console 导向 stderr,避免污染协议 stdout。
|
|
48
|
+
const originalConsole = { ...console };
|
|
49
|
+
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
50
|
+
console[level] = (...parts) => {
|
|
51
|
+
writeStderrLine(level === "log" ? "info" : level, parts.map(String).join(" "));
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createLogger() {
|
|
56
|
+
return {
|
|
57
|
+
debug(message, fields) {
|
|
58
|
+
writeStderrLine("debug", message, fields || {});
|
|
59
|
+
},
|
|
60
|
+
info(message, fields) {
|
|
61
|
+
writeStderrLine("info", message, fields || {});
|
|
62
|
+
},
|
|
63
|
+
warn(message, fields) {
|
|
64
|
+
writeStderrLine("warn", message, fields || {});
|
|
65
|
+
},
|
|
66
|
+
error(message, fields) {
|
|
67
|
+
writeStderrLine("error", message, fields || {});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function definePlugin(plugin) {
|
|
73
|
+
return plugin;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadPackageJson(pluginRoot) {
|
|
77
|
+
const packagePath = path.join(pluginRoot, "package.json");
|
|
78
|
+
const raw = fs.readFileSync(packagePath, "utf8");
|
|
79
|
+
return JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function importPlugin(entryPath) {
|
|
83
|
+
const url = pathToFileURL(entryPath).href;
|
|
84
|
+
const mod = await import(url);
|
|
85
|
+
return mod.default || mod.plugin || mod;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class PluginRuntime {
|
|
89
|
+
constructor(pluginRoot) {
|
|
90
|
+
this.pluginRoot = path.resolve(pluginRoot);
|
|
91
|
+
this.packageJson = loadPackageJson(this.pluginRoot);
|
|
92
|
+
this.manifest = this.packageJson.omnicrawl || {};
|
|
93
|
+
this.handlers = new Map();
|
|
94
|
+
this.plugin = null;
|
|
95
|
+
this.initialized = false;
|
|
96
|
+
this.activeCancellations = new Set();
|
|
97
|
+
this.logger = createLogger();
|
|
98
|
+
this._hostRequestId = 1;
|
|
99
|
+
this._pendingHostRequests = new Map();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Host 对 Worker 请求的响应入口(custom.emit 等)。 */
|
|
103
|
+
resolveHostResponse(message) {
|
|
104
|
+
if (!message || message.id == null) return false;
|
|
105
|
+
const pending = this._pendingHostRequests.get(message.id);
|
|
106
|
+
if (!pending) return false;
|
|
107
|
+
this._pendingHostRequests.delete(message.id);
|
|
108
|
+
if (message.error) {
|
|
109
|
+
pending.reject(new Error(message.error.message || String(message.error)));
|
|
110
|
+
} else {
|
|
111
|
+
pending.resolve(message.result || { ok: true });
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_requestHost(method, params = {}) {
|
|
117
|
+
const id = this._hostRequestId;
|
|
118
|
+
this._hostRequestId += 1;
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
this._pendingHostRequests.set(id, { resolve, reject });
|
|
121
|
+
writeStdout({
|
|
122
|
+
jsonrpc: "2.0",
|
|
123
|
+
id,
|
|
124
|
+
method,
|
|
125
|
+
params,
|
|
126
|
+
});
|
|
127
|
+
// Host 超时由 Python 侧控制;Worker 侧给一个安全兜底。
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
if (this._pendingHostRequests.has(id)) {
|
|
130
|
+
this._pendingHostRequests.delete(id);
|
|
131
|
+
reject(new Error(`Host 请求超时:${method}`));
|
|
132
|
+
}
|
|
133
|
+
}, 10000);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async initialize(params = {}) {
|
|
138
|
+
if (this.initialized) {
|
|
139
|
+
return this._registrationSnapshot();
|
|
140
|
+
}
|
|
141
|
+
const entryRel = this.manifest.entry || this.packageJson.main;
|
|
142
|
+
if (!entryRel) {
|
|
143
|
+
throw new Error("package.json 缺少 omnicrawl.entry / main");
|
|
144
|
+
}
|
|
145
|
+
const entryPath = path.resolve(this.pluginRoot, entryRel);
|
|
146
|
+
const rootResolved = path.resolve(this.pluginRoot);
|
|
147
|
+
if (!entryPath.startsWith(rootResolved + path.sep) && entryPath !== rootResolved) {
|
|
148
|
+
throw new Error(`entry 逃逸包根目录:${entryRel}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const imported = await importPlugin(entryPath);
|
|
152
|
+
const definition = typeof imported === "function" ? imported({ definePlugin }) : imported;
|
|
153
|
+
if (!definition || typeof definition.activate !== "function") {
|
|
154
|
+
throw new Error("插件必须导出 activate(context) 的 PluginDefinition");
|
|
155
|
+
}
|
|
156
|
+
this.plugin = definition;
|
|
157
|
+
|
|
158
|
+
const declared = Array.isArray(this.manifest.hooks) ? this.manifest.hooks : [];
|
|
159
|
+
const declaredIds = new Set(declared.map((item) => item.id));
|
|
160
|
+
|
|
161
|
+
const context = {
|
|
162
|
+
plugin: {
|
|
163
|
+
name: this.packageJson.name,
|
|
164
|
+
version: this.packageJson.version,
|
|
165
|
+
apiVersion: String(this.manifest.apiVersion || "1"),
|
|
166
|
+
},
|
|
167
|
+
runtime: {
|
|
168
|
+
omnicrawlVersion: params.omnicrawlVersion || process.env.OMNICRAWL_VERSION || "0.1.5",
|
|
169
|
+
nodeVersion: process.version,
|
|
170
|
+
},
|
|
171
|
+
hooks: {
|
|
172
|
+
on: (registration) => {
|
|
173
|
+
if (!registration || typeof registration !== "object") {
|
|
174
|
+
throw new Error("hooks.on(registration) 需要对象");
|
|
175
|
+
}
|
|
176
|
+
const id = String(registration.id || "");
|
|
177
|
+
const hook = String(registration.hook || "");
|
|
178
|
+
const mode = String(registration.mode || "observe");
|
|
179
|
+
const handler = registration.handler;
|
|
180
|
+
if (!declaredIds.has(id)) {
|
|
181
|
+
throw new Error(`运行期不能注册 manifest 未声明的 Handler:${id}`);
|
|
182
|
+
}
|
|
183
|
+
if (typeof handler !== "function") {
|
|
184
|
+
throw new Error(`Handler ${id} 缺少 handler 函数`);
|
|
185
|
+
}
|
|
186
|
+
this.handlers.set(id, { id, hook, mode, handler });
|
|
187
|
+
return () => {
|
|
188
|
+
this.handlers.delete(id);
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
emitCustom: async (event, payload = {}, options = {}) => {
|
|
192
|
+
if (!this.initialized) {
|
|
193
|
+
throw new Error("initialize 完成前不能 emitCustom");
|
|
194
|
+
}
|
|
195
|
+
const permissions = Array.isArray(this.manifest.permissions)
|
|
196
|
+
? this.manifest.permissions
|
|
197
|
+
: [];
|
|
198
|
+
if (!permissions.includes("hook:custom-emit")) {
|
|
199
|
+
throw new Error("缺少 hook:custom-emit 权限");
|
|
200
|
+
}
|
|
201
|
+
const version =
|
|
202
|
+
options.version != null
|
|
203
|
+
? Number(options.version)
|
|
204
|
+
: Number(
|
|
205
|
+
(Array.isArray(this.manifest.customEvents)
|
|
206
|
+
? this.manifest.customEvents.find((item) => item.name === event)
|
|
207
|
+
: null)?.version || 1
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// 同插件订阅必须本地投递:Host 若在 hook.invoke 中再回调本 Worker
|
|
211
|
+
// 会与“等待 custom.emit 响应”形成死锁。
|
|
212
|
+
let localDelivered = 0;
|
|
213
|
+
const localEvent = {
|
|
214
|
+
apiVersion: String(this.manifest.apiVersion || "1"),
|
|
215
|
+
hook: event,
|
|
216
|
+
payload: payload || {},
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
for (const binding of this.handlers.values()) {
|
|
220
|
+
if (binding.hook !== event) continue;
|
|
221
|
+
await binding.handler(localEvent);
|
|
222
|
+
localDelivered += 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = await this._requestHost("custom.emit", {
|
|
226
|
+
event,
|
|
227
|
+
version,
|
|
228
|
+
payload: payload || {},
|
|
229
|
+
localDelivered,
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
...(result || { ok: true }),
|
|
233
|
+
localDelivered,
|
|
234
|
+
delivered: Number(result?.delivered || 0) + localDelivered,
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
logger: this.logger,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
await definition.activate(context);
|
|
242
|
+
|
|
243
|
+
// 若插件未在 activate 中显式 on(),但 manifest 声明了 hooks,则保持空绑定;
|
|
244
|
+
// Host 会在二次校验时发现实际注册子集。
|
|
245
|
+
this.initialized = true;
|
|
246
|
+
return this._registrationSnapshot();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_registrationSnapshot() {
|
|
250
|
+
const registrations = [];
|
|
251
|
+
for (const item of this.handlers.values()) {
|
|
252
|
+
registrations.push({
|
|
253
|
+
id: item.id,
|
|
254
|
+
hook: item.hook,
|
|
255
|
+
mode: item.mode,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// 若 activate 没手动 on,回落为 manifest 声明(开发期便利);
|
|
259
|
+
// Host 仍要求它们是 manifest 子集。
|
|
260
|
+
if (registrations.length === 0 && Array.isArray(this.manifest.hooks)) {
|
|
261
|
+
for (const item of this.manifest.hooks) {
|
|
262
|
+
registrations.push({
|
|
263
|
+
id: item.id,
|
|
264
|
+
hook: item.hook,
|
|
265
|
+
mode: item.mode,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
plugin: {
|
|
271
|
+
name: this.packageJson.name,
|
|
272
|
+
version: this.packageJson.version,
|
|
273
|
+
apiVersion: String(this.manifest.apiVersion || "1"),
|
|
274
|
+
},
|
|
275
|
+
handlers: registrations,
|
|
276
|
+
capabilities: {
|
|
277
|
+
customEmit: Boolean(
|
|
278
|
+
Array.isArray(this.manifest.permissions) &&
|
|
279
|
+
this.manifest.permissions.includes("hook:custom-emit")
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async invoke(handlerId, event) {
|
|
286
|
+
const binding = this.handlers.get(handlerId);
|
|
287
|
+
if (!binding) {
|
|
288
|
+
// 允许仅声明未绑定:默认 continue,便于 observe 空实现。
|
|
289
|
+
return { action: "continue" };
|
|
290
|
+
}
|
|
291
|
+
const result = await binding.handler(event);
|
|
292
|
+
if (result == null) {
|
|
293
|
+
return { action: "continue" };
|
|
294
|
+
}
|
|
295
|
+
if (typeof result !== "object") {
|
|
296
|
+
throw new Error(`Handler ${handlerId} 必须返回对象`);
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async shutdown() {
|
|
302
|
+
if (this.plugin && typeof this.plugin.deactivate === "function") {
|
|
303
|
+
await this.plugin.deactivate();
|
|
304
|
+
}
|
|
305
|
+
this.handlers.clear();
|
|
306
|
+
this.initialized = false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function main() {
|
|
311
|
+
const args = parseArgs(process.argv.slice(2));
|
|
312
|
+
if (!args.pluginRoot) {
|
|
313
|
+
writeStderrLine("error", "missing --plugin-root");
|
|
314
|
+
process.exit(2);
|
|
315
|
+
}
|
|
316
|
+
const runtime = new PluginRuntime(args.pluginRoot);
|
|
317
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
318
|
+
|
|
319
|
+
const sendResult = (id, result) => {
|
|
320
|
+
writeStdout({ jsonrpc: "2.0", id, result });
|
|
321
|
+
};
|
|
322
|
+
const sendError = (id, code, message) => {
|
|
323
|
+
writeStdout({ jsonrpc: "2.0", id, error: { code, message } });
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// 必须用事件驱动读取 stdin:handler 内 await custom.emit 时 Host 响应
|
|
327
|
+
// 也要从同一 stdin 进来。若 for-await 串行阻塞,会形成死锁超时。
|
|
328
|
+
let shuttingDown = false;
|
|
329
|
+
const inflight = new Set();
|
|
330
|
+
|
|
331
|
+
const handleMessage = async (message) => {
|
|
332
|
+
// Host 对 Worker 请求的响应:无 method,带 id+result/error。
|
|
333
|
+
if (
|
|
334
|
+
message.id != null &&
|
|
335
|
+
(Object.prototype.hasOwnProperty.call(message, "result") ||
|
|
336
|
+
Object.prototype.hasOwnProperty.call(message, "error")) &&
|
|
337
|
+
!message.method
|
|
338
|
+
) {
|
|
339
|
+
runtime.resolveHostResponse(message);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { id, method, params } = message;
|
|
344
|
+
try {
|
|
345
|
+
if (method === "initialize") {
|
|
346
|
+
const result = await runtime.initialize(params || {});
|
|
347
|
+
if (id !== undefined) sendResult(id, result);
|
|
348
|
+
} else if (method === "hook.invoke") {
|
|
349
|
+
const handlerId = params?.handlerId;
|
|
350
|
+
const event = params?.event || {};
|
|
351
|
+
const result = await runtime.invoke(handlerId, event);
|
|
352
|
+
if (id !== undefined) sendResult(id, result);
|
|
353
|
+
} else if (method === "hook.cancel") {
|
|
354
|
+
const requestId = params?.requestId;
|
|
355
|
+
if (requestId != null) runtime.activeCancellations.add(requestId);
|
|
356
|
+
if (id !== undefined) sendResult(id, { ok: true });
|
|
357
|
+
} else if (method === "ping") {
|
|
358
|
+
if (id !== undefined) sendResult(id, { ok: true, ts: Date.now() });
|
|
359
|
+
} else if (method === "shutdown") {
|
|
360
|
+
shuttingDown = true;
|
|
361
|
+
await runtime.shutdown();
|
|
362
|
+
if (id !== undefined) sendResult(id, { ok: true });
|
|
363
|
+
process.exit(0);
|
|
364
|
+
} else if (method) {
|
|
365
|
+
if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`);
|
|
366
|
+
}
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
369
|
+
writeStderrLine("error", text);
|
|
370
|
+
if (id !== undefined) sendError(id, -32000, text);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
rl.on("line", (line) => {
|
|
375
|
+
if (shuttingDown) return;
|
|
376
|
+
const trimmed = String(line || "").trim();
|
|
377
|
+
if (!trimmed) return;
|
|
378
|
+
let message;
|
|
379
|
+
try {
|
|
380
|
+
message = JSON.parse(trimmed);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
writeStderrLine("error", "invalid json from host", { error: String(error) });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!message || typeof message !== "object") return;
|
|
386
|
+
const task = handleMessage(message);
|
|
387
|
+
inflight.add(task);
|
|
388
|
+
task.finally(() => inflight.delete(task));
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await new Promise((resolve) => {
|
|
392
|
+
rl.on("close", resolve);
|
|
393
|
+
});
|
|
394
|
+
await Promise.allSettled([...inflight]);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
main().catch((error) => {
|
|
398
|
+
writeStderrLine("error", error instanceof Error ? error.message : String(error));
|
|
399
|
+
process.exit(1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
export { definePlugin };
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""OneJev 本地决策 worker:在命名管道(Windows)/ Unix socket 上提供 System One 判定。
|
|
2
|
+
|
|
3
|
+
为什么不用 `qev serve`:官方那个服务经 uvicorn/FastAPI 暴露 HTTP,本机调用方因此要为
|
|
4
|
+
一次纯内存判定多付一层 TCP + ASGI 中转。这个 worker 直接持有 `qev` 的引擎对象,
|
|
5
|
+
请求从管道进来、答案从管道出去,中间只有一次 JSON 编解码。
|
|
6
|
+
|
|
7
|
+
协议(NDJSON,一行一个 JSON 对象):
|
|
8
|
+
|
|
9
|
+
请求 {"model": "...", "state": {...}, "questions": {...}}
|
|
10
|
+
成功 {"model": "...", "answers": {...}, "usage": {...}, "qev": {...}}
|
|
11
|
+
失败 {"error": "可读原因"}
|
|
12
|
+
|
|
13
|
+
响应形状故意与 HTTP 端点一致,Rust 侧读答案的代码因此不必区分两种传输。
|
|
14
|
+
|
|
15
|
+
服务是**本机共用**的常驻进程,由 OmniCrawl 宿主以脱离进程的方式拉起(见
|
|
16
|
+
`omnicrawl-onejev` 的 `server` 模块):加载权重、创建端点、打出就绪标记,之后一直服务;
|
|
17
|
+
停止只能显式做(宿主按 PID 回收整棵进程树)。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import socket
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# 就绪标记:宿主按它认定「服务已经能接请求」(写进服务日志)。
|
|
33
|
+
READY_MARKER = "[onejev] 服务就绪"
|
|
34
|
+
# 单次连接的等待上限(秒):客户端总是连上就发,静默超时说明对端已经不在了。
|
|
35
|
+
CONNECTION_TIMEOUT_SECONDS = 60.0
|
|
36
|
+
# 管道缓冲区:一次判定请求的 state 上限是 64k 字符级别,留一倍余量。
|
|
37
|
+
PIPE_BUFFER_BYTES = 128 * 1024
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("onejev.worker")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Decider:
|
|
43
|
+
"""引擎 + 判定;串行化——一次前向独占前缀缓存,并发只会互相踩。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, engine, model_name: str) -> None:
|
|
46
|
+
self.engine = engine
|
|
47
|
+
self.model_name = model_name
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
def answer(self, body: dict) -> dict:
|
|
51
|
+
from qev.schema import SystemOneRequest
|
|
52
|
+
|
|
53
|
+
request = SystemOneRequest.model_validate(body)
|
|
54
|
+
with self._lock:
|
|
55
|
+
response, meta = self.engine.decide(request)
|
|
56
|
+
response.model = self.model_name
|
|
57
|
+
payload = response.model_dump()
|
|
58
|
+
payload["qev"] = meta
|
|
59
|
+
return payload
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _handle_connection(decider: Decider, read_line, write_line, close) -> None:
|
|
63
|
+
"""一次连接 = 一次判定:读一行请求,回一行响应(成功或错误),然后收线。"""
|
|
64
|
+
try:
|
|
65
|
+
line = read_line()
|
|
66
|
+
if not line:
|
|
67
|
+
return
|
|
68
|
+
payload = decider.answer(json.loads(line))
|
|
69
|
+
except Exception as error: # 请求不合法 / 引擎报错:回一行可读原因,不猜答案
|
|
70
|
+
log.warning("判定失败:%s", error)
|
|
71
|
+
message = str(error) or error.__class__.__name__
|
|
72
|
+
try:
|
|
73
|
+
write_line(json.dumps({"error": message}, ensure_ascii=False))
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
else:
|
|
77
|
+
write_line(json.dumps(payload, ensure_ascii=False))
|
|
78
|
+
finally:
|
|
79
|
+
close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
# Windows:命名管道
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def windows_pipe_io(endpoint: str, decider: Decider) -> None:
|
|
88
|
+
import ctypes
|
|
89
|
+
import ctypes.wintypes as wintypes
|
|
90
|
+
import msvcrt
|
|
91
|
+
|
|
92
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
93
|
+
|
|
94
|
+
pipe_access_duplex = 0x00000003
|
|
95
|
+
pipe_type_byte = 0x00000000
|
|
96
|
+
pipe_readmode_byte = 0x00000000
|
|
97
|
+
pipe_wait = 0x00000000
|
|
98
|
+
pipe_unlimited_instances = 255
|
|
99
|
+
invalid_handle = wintypes.HANDLE(-1).value
|
|
100
|
+
error_pipe_connected = 535
|
|
101
|
+
|
|
102
|
+
kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
|
|
103
|
+
kernel32.CreateNamedPipeW.argtypes = [
|
|
104
|
+
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD,
|
|
105
|
+
wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
|
|
106
|
+
]
|
|
107
|
+
kernel32.ConnectNamedPipe.restype = wintypes.BOOL
|
|
108
|
+
kernel32.ConnectNamedPipe.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
|
109
|
+
kernel32.PeekNamedPipe.restype = wintypes.BOOL
|
|
110
|
+
kernel32.PeekNamedPipe.argtypes = [
|
|
111
|
+
wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD,
|
|
112
|
+
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD),
|
|
113
|
+
]
|
|
114
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
115
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
116
|
+
|
|
117
|
+
def create_instance() -> int:
|
|
118
|
+
return kernel32.CreateNamedPipeW(
|
|
119
|
+
endpoint,
|
|
120
|
+
pipe_access_duplex,
|
|
121
|
+
pipe_type_byte | pipe_readmode_byte | pipe_wait,
|
|
122
|
+
pipe_unlimited_instances,
|
|
123
|
+
PIPE_BUFFER_BYTES,
|
|
124
|
+
PIPE_BUFFER_BYTES,
|
|
125
|
+
0,
|
|
126
|
+
None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def serve_instance(handle: int) -> None:
|
|
130
|
+
fd = msvcrt.open_osfhandle(handle, os.O_RDWR | os.O_BINARY)
|
|
131
|
+
stream = os.fdopen(fd, "r+b", buffering=0)
|
|
132
|
+
|
|
133
|
+
def read_line() -> str:
|
|
134
|
+
buffer = bytearray()
|
|
135
|
+
deadline = time.monotonic() + CONNECTION_TIMEOUT_SECONDS
|
|
136
|
+
while b"\n" not in buffer:
|
|
137
|
+
available = wintypes.DWORD(0)
|
|
138
|
+
kernel32.PeekNamedPipe(
|
|
139
|
+
wintypes.HANDLE(handle), None, 0, None, ctypes.byref(available), None
|
|
140
|
+
)
|
|
141
|
+
if available.value:
|
|
142
|
+
chunk = stream.read(available.value)
|
|
143
|
+
if not chunk:
|
|
144
|
+
break
|
|
145
|
+
buffer += chunk
|
|
146
|
+
continue
|
|
147
|
+
if time.monotonic() > deadline:
|
|
148
|
+
break
|
|
149
|
+
time.sleep(0.005)
|
|
150
|
+
return bytes(buffer).split(b"\n", 1)[0].decode("utf-8", "replace")
|
|
151
|
+
|
|
152
|
+
def write_line(text: str) -> None:
|
|
153
|
+
stream.write(text.encode("utf-8") + b"\n")
|
|
154
|
+
|
|
155
|
+
# 关闭 fd 即关闭 handle(`open_osfhandle` 把它交给了 fd)。
|
|
156
|
+
_handle_connection(decider, read_line, write_line, stream.close)
|
|
157
|
+
|
|
158
|
+
# 实例先建出来再报就绪:宿主看到标记时端点必须已经能被连上。
|
|
159
|
+
first = create_instance()
|
|
160
|
+
if first == invalid_handle:
|
|
161
|
+
raise SystemExit(f"创建命名管道失败:{ctypes.get_last_error()}({endpoint})")
|
|
162
|
+
print(f"{READY_MARKER} endpoint={endpoint}", flush=True)
|
|
163
|
+
|
|
164
|
+
pending = first
|
|
165
|
+
while True:
|
|
166
|
+
handle = pending
|
|
167
|
+
if not kernel32.ConnectNamedPipe(wintypes.HANDLE(handle), None):
|
|
168
|
+
error = ctypes.get_last_error()
|
|
169
|
+
# ERROR_PIPE_CONNECTED 说明客户端已经连上了这个实例,照旧交给服务线程。
|
|
170
|
+
if error != error_pipe_connected:
|
|
171
|
+
kernel32.CloseHandle(wintypes.HANDLE(handle))
|
|
172
|
+
pending = create_instance()
|
|
173
|
+
continue
|
|
174
|
+
# 一个实例只服务一个连接;下一个实例提前建好,客户端不会撞到「管道不存在」。
|
|
175
|
+
pending = create_instance()
|
|
176
|
+
threading.Thread(target=serve_instance, args=(handle,), daemon=True).start()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# Unix:Unix domain socket(没有 TCP 端口,也没有 HTTP 中转)
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def unix_socket_io(endpoint: str, decider: Decider) -> None:
|
|
185
|
+
path = Path(endpoint)
|
|
186
|
+
if path.exists():
|
|
187
|
+
path.unlink()
|
|
188
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
189
|
+
server.bind(endpoint)
|
|
190
|
+
server.listen(64)
|
|
191
|
+
os.chmod(endpoint, 0o600)
|
|
192
|
+
print(f"{READY_MARKER} endpoint={endpoint}", flush=True)
|
|
193
|
+
|
|
194
|
+
def serve_connection(connection: socket.socket) -> None:
|
|
195
|
+
stream = connection.makefile("rwb", buffering=0)
|
|
196
|
+
|
|
197
|
+
def read_line() -> str:
|
|
198
|
+
connection.settimeout(CONNECTION_TIMEOUT_SECONDS)
|
|
199
|
+
line = stream.readline()
|
|
200
|
+
return line.decode("utf-8", "replace").rstrip("\n") if line else ""
|
|
201
|
+
|
|
202
|
+
def write_line(text: str) -> None:
|
|
203
|
+
stream.write(text.encode("utf-8") + b"\n")
|
|
204
|
+
|
|
205
|
+
def close() -> None:
|
|
206
|
+
try:
|
|
207
|
+
stream.close()
|
|
208
|
+
finally:
|
|
209
|
+
connection.close()
|
|
210
|
+
|
|
211
|
+
_handle_connection(decider, read_line, write_line, close)
|
|
212
|
+
|
|
213
|
+
while True:
|
|
214
|
+
connection, _ = server.accept()
|
|
215
|
+
threading.Thread(target=serve_connection, args=(connection,), daemon=True).start()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --------------------------------------------------------------------------
|
|
219
|
+
# 引擎装配(与 `qev serve` 同口径,但不启 HTTP 服务)
|
|
220
|
+
# --------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_engine(args: argparse.Namespace):
|
|
224
|
+
from qev.calibrate import Calibration
|
|
225
|
+
from qev.mm_engine import local_model_dir
|
|
226
|
+
|
|
227
|
+
model_path = local_model_dir(args.model)
|
|
228
|
+
local = Path(model_path) / "calibration.json"
|
|
229
|
+
calib_path = args.calibration or (str(local) if local.exists() else None)
|
|
230
|
+
calibration = Calibration.load(calib_path) if calib_path else None
|
|
231
|
+
if calib_path:
|
|
232
|
+
log.info("calibration: %s", calib_path)
|
|
233
|
+
|
|
234
|
+
config = json.loads((Path(model_path) / "config.json").read_text())
|
|
235
|
+
if args.multimodal or config.get("vision_config") is not None:
|
|
236
|
+
from qev.mm_engine import MMDecisionEngine
|
|
237
|
+
|
|
238
|
+
return MMDecisionEngine(
|
|
239
|
+
model_path,
|
|
240
|
+
device=args.device,
|
|
241
|
+
dtype=args.dtype,
|
|
242
|
+
calibration=calibration,
|
|
243
|
+
fork_mode=args.fork_mode,
|
|
244
|
+
head_dtype=args.head_dtype,
|
|
245
|
+
max_branch_tokens=args.max_branch_tokens,
|
|
246
|
+
max_request_tokens=args.max_request_tokens,
|
|
247
|
+
gpu_preprocess=not args.no_gpu_preprocess,
|
|
248
|
+
cuda_graphs=not args.no_cuda_graphs,
|
|
249
|
+
)
|
|
250
|
+
from qev.engine import DecisionEngine
|
|
251
|
+
|
|
252
|
+
return DecisionEngine(
|
|
253
|
+
model_path,
|
|
254
|
+
device=args.device,
|
|
255
|
+
dtype=args.dtype,
|
|
256
|
+
calibration=calibration,
|
|
257
|
+
fork_mode=args.fork_mode,
|
|
258
|
+
head_dtype=args.head_dtype,
|
|
259
|
+
max_branch_tokens=args.max_branch_tokens,
|
|
260
|
+
max_request_tokens=args.max_request_tokens,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main() -> None:
|
|
265
|
+
parser = argparse.ArgumentParser(prog="onejev_worker")
|
|
266
|
+
parser.add_argument("--model", required=True, help="本地权重目录(或 Hub id)")
|
|
267
|
+
parser.add_argument("--name", default=None, help="对外的模型名")
|
|
268
|
+
parser.add_argument("--endpoint", required=True, help="命名管道名或 Unix socket 路径")
|
|
269
|
+
parser.add_argument("--device", default="cuda")
|
|
270
|
+
parser.add_argument("--dtype", default="bfloat16")
|
|
271
|
+
parser.add_argument("--head-dtype", default="float32")
|
|
272
|
+
parser.add_argument("--fork-mode", default="auto")
|
|
273
|
+
parser.add_argument("--multimodal", action="store_true")
|
|
274
|
+
parser.add_argument("--no-cuda-graphs", action="store_true")
|
|
275
|
+
parser.add_argument("--no-gpu-preprocess", action="store_true")
|
|
276
|
+
parser.add_argument("--calibration", default=None)
|
|
277
|
+
parser.add_argument("--max-branch-tokens", type=int, default=32768)
|
|
278
|
+
parser.add_argument("--max-request-tokens", type=int, default=65536)
|
|
279
|
+
args = parser.parse_args()
|
|
280
|
+
|
|
281
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
282
|
+
|
|
283
|
+
engine = build_engine(args)
|
|
284
|
+
decider = Decider(engine, args.name or Path(args.model).name)
|
|
285
|
+
|
|
286
|
+
if sys.platform == "win32":
|
|
287
|
+
windows_pipe_io(args.endpoint, decider)
|
|
288
|
+
else:
|
|
289
|
+
unix_socket_io(args.endpoint, decider)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|