cabletidy 0.2.0 → 0.3.0-rc.2

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.
@@ -1,129 +0,0 @@
1
- import callbackFs from "node:fs";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { randomUUID } from "node:crypto";
6
- import lockfile from "proper-lockfile";
7
- import { readRuntimeInfo } from "./config.mjs";
8
- import { inspectProcess, processStartTime } from "./process-identity.mjs";
9
-
10
- const STALE_MS = 10_000;
11
- const ownerName = /^owner-[\da-f-]{36}\.json$/;
12
-
13
- export async function acquireInstanceLock(paths) {
14
- const identity = { pid: process.pid, startTime: await processStartTime(process.pid), hostname: os.hostname() };
15
- const marker = `owner-${randomUUID()}.json`;
16
- const filesystem = {
17
- ...callbackFs,
18
- mkdir(directory, callback) {
19
- publishOwner(directory, marker, identity).then(() => callback(), callback);
20
- },
21
- rmdir(directory, callback) {
22
- removeOwner(directory, marker).then(() => callback(), callback);
23
- },
24
- rmdirSync(directory) {
25
- try {
26
- callbackFs.unlinkSync(path.join(directory, marker));
27
- callbackFs.rmdirSync(directory);
28
- } catch (error) {
29
- if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
30
- }
31
- },
32
- };
33
- while (true) {
34
- try {
35
- const release = await lockfile.lock(paths.home, {
36
- lockfilePath: paths.lock,
37
- // Age alone must never evict a paused process. Reclaim is handled below.
38
- stale: Number.MAX_SAFE_INTEGER,
39
- update: 2000,
40
- fs: filesystem,
41
- });
42
- return { release, identity };
43
- } catch (error) {
44
- if (error.code !== "ELOCKED") throw error;
45
- const inspection = await inspectLock(paths);
46
- if (!inspection) continue;
47
- if (inspection.state === "alive" || !inspection.stale) {
48
- throw Object.assign(new Error(
49
- `此数据目录已有 CableTidy 实例运行,或异常退出后的锁尚未过期 (ELOCKED);异常退出后等待 10 秒再重试: ${paths.home}`,
50
- ), { code: "ELOCKED" });
51
- }
52
- if (inspection.state === "unknown" || !inspection.marker) {
53
- throw Object.assign(new Error(
54
- `无法确认旧实例锁的进程身份 (ELOCKUNKNOWN${inspection.pid ? `, PID ${inspection.pid}` : ""})。请先检查并停止使用此数据目录的 CableTidy;确认实例已退出后,手动删除锁目录并重新启动: ${paths.lock}`,
55
- ), { code: "ELOCKUNKNOWN" });
56
- }
57
- await removeOwner(paths.lock, inspection.marker);
58
- }
59
- }
60
- }
61
-
62
- async function publishOwner(directory, marker, identity) {
63
- // Publish a populated directory atomically, so reclaimers never see a new
64
- // owner without its identity and cannot rmdir a newly acquired lock.
65
- try {
66
- await fs.lstat(directory);
67
- throw Object.assign(new Error("Lock exists"), { code: "EEXIST" });
68
- } catch (error) {
69
- if (error.code !== "ENOENT") throw error;
70
- }
71
- const staged = await fs.mkdtemp(path.join(path.dirname(directory), ".daemon-lock-"));
72
- try {
73
- await fs.writeFile(path.join(staged, marker), JSON.stringify(identity), { mode: 0o600, flag: "wx" });
74
- try {
75
- await fs.rename(staged, directory);
76
- } catch (error) {
77
- // Windows reports EPERM/EACCES for an existing destination directory.
78
- if (["ENOTEMPTY", "EEXIST", "EPERM", "EACCES"].includes(error.code)) {
79
- await fs.lstat(directory);
80
- throw Object.assign(error, { code: "EEXIST" });
81
- }
82
- throw error;
83
- }
84
- } finally {
85
- await fs.rm(staged, { recursive: true, force: true });
86
- }
87
- }
88
-
89
- async function removeOwner(directory, marker) {
90
- try {
91
- // Only remove the inspected generation. A losing reclaimer cannot remove
92
- // the new owner's unique marker, and rmdir refuses a populated directory.
93
- await fs.unlink(path.join(directory, marker));
94
- await fs.rmdir(directory);
95
- } catch (error) {
96
- if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
97
- }
98
- }
99
-
100
- async function inspectLock(paths) {
101
- try {
102
- const entries = await fs.readdir(paths.lock);
103
- const markers = entries.filter(name => ownerName.test(name) || name === "owner.json");
104
- const marker = entries.length === 1 && markers.length === 1 ? markers[0] : null;
105
- let identity;
106
- if (marker) {
107
- try { identity = JSON.parse(await fs.readFile(path.join(paths.lock, marker), "utf8")); }
108
- catch (error) {
109
- if (error.code === "ENOENT") return null;
110
- if (error.name !== "SyntaxError") throw error;
111
- }
112
- } else if (!entries.length) {
113
- // Legacy empty locks have no generation token, so even a dead runtime
114
- // PID cannot make their removal safe against concurrent reclaimers.
115
- try {
116
- const runtime = await readRuntimeInfo(paths);
117
- identity = runtime && { pid: runtime.pid, startTime: runtime.pidStartTime };
118
- } catch { /* Missing or malformed legacy metadata requires manual recovery. */ }
119
- }
120
- const state = await inspectProcess(identity);
121
- // Another reclaimer may have removed its marker while we read the entries.
122
- // Read freshness last so that transition is not mistaken for an old empty lock.
123
- const stat = await fs.stat(paths.lock);
124
- return { marker, pid: identity?.pid, state, stale: Date.now() - stat.mtimeMs >= STALE_MS };
125
- } catch (error) {
126
- if (error.code === "ENOENT") return null;
127
- throw error;
128
- }
129
- }
@@ -1,185 +0,0 @@
1
- export class ModelResolveError extends Error {
2
- constructor(code, message, details = {}) {
3
- super(message);
4
- this.name = "ModelResolveError";
5
- this.code = code;
6
- this.details = details;
7
- }
8
- }
9
-
10
- export function findModelProfile(config, clientModelId, allowedModels) {
11
- if (!clientModelId) return null;
12
- const allowed = Array.isArray(allowedModels) ? new Set(allowedModels) : null;
13
- if (config.models?.[clientModelId] && (!allowed || allowed.has(clientModelId))) {
14
- return { profileId: clientModelId, profile: config.models[clientModelId] };
15
- }
16
- for (const [profileId, profile] of Object.entries(config.models || {})) {
17
- if (allowed && !allowed.has(profileId)) continue;
18
- if (
19
- profile.clientModelId === clientModelId ||
20
- (profile.aliases || []).includes(clientModelId)
21
- ) {
22
- return { profileId, profile };
23
- }
24
- }
25
- return null;
26
- }
27
-
28
- export function clientModelIdForProfile(profileId, profile) {
29
- return profile?.clientModelId || profile?.aliases?.[0] || profileId;
30
- }
31
-
32
- export function listClientModels(config, virtualProvider) {
33
- const allowed = virtualProvider?.allowedModels || [];
34
- return allowed
35
- .map((profileId) => {
36
- const profile = config.models?.[profileId];
37
- if (!profile) return null;
38
- return {
39
- id: clientModelIdForProfile(profileId, profile),
40
- profileId,
41
- aliases: profile.aliases || [],
42
- family: profile.family || "unknown",
43
- capabilities: profile.capabilities || [],
44
- object: "model",
45
- owned_by: "cabletidy",
46
- };
47
- })
48
- .filter(Boolean);
49
- }
50
-
51
- export function resolveModelProfile(config, virtualProvider, requestedModel) {
52
- if (requestedModel !== undefined && (typeof requestedModel !== "string" || !requestedModel.trim())) {
53
- throw new ModelResolveError("invalid_model", "model 必须是非空字符串");
54
- }
55
- const clientModelId =
56
- requestedModel ||
57
- virtualProvider?.defaultModel ||
58
- null;
59
- if (!clientModelId) {
60
- throw new ModelResolveError("model_required", "请求没有模型,Virtual Provider 也没有默认模型");
61
- }
62
-
63
- // allowedModels scopes optional overrides, not the models a client may request.
64
- const match = findModelProfile(config, clientModelId, virtualProvider?.allowedModels || []);
65
- if (!match) {
66
- return { clientModelId, profileId: null, profile: null };
67
- }
68
-
69
- return {
70
- clientModelId: requestedModel || clientModelIdForProfile(match.profileId, match.profile),
71
- profileId: match.profileId,
72
- profile: match.profile,
73
- };
74
- }
75
-
76
- export function effectiveCapabilities(profile, binding) {
77
- const capabilities = new Set(profile?.capabilities || []);
78
- for (const item of binding?.capabilityOverrides || []) {
79
- if (item.startsWith("-")) capabilities.delete(item.slice(1));
80
- else if (item.startsWith("+")) capabilities.add(item.slice(1));
81
- else capabilities.add(item);
82
- }
83
- if (profile?.codex?.metadataMode === "override" &&
84
- profile.codex.inputModalities && !profile.codex.inputModalities.includes("image")) {
85
- capabilities.delete("vision");
86
- }
87
- return capabilities;
88
- }
89
-
90
- function requiredCapabilities(request = {}) {
91
- const required = new Set();
92
- if (request.stream) required.add("streaming");
93
- if (Array.isArray(request.tools) && request.tools.length) required.add("tools");
94
- if (request.tool_choice) required.add("tools");
95
- if (request.parallel_tool_calls) required.add("parallel_tool_calls");
96
- if (request.reasoning) required.add("reasoning");
97
- if (request.thinking && request.thinking.type !== "disabled") required.add("reasoning");
98
- if (request.images || request.image_input || containsImage(request.input) || containsImage(request.messages)) required.add("vision");
99
- return required;
100
- }
101
-
102
- function containsImage(value) {
103
- if (Array.isArray(value)) return value.some(containsImage);
104
- if (!value || typeof value !== "object") return false;
105
- if (["input_image", "image", "image_url"].includes(value.type)) return true;
106
- return containsImage(value.content);
107
- }
108
-
109
- function hasCapabilities(capabilities, required) {
110
- return [...required].every((item) => capabilities.has(item));
111
- }
112
-
113
- export function selectBackend(config, virtualProvider, modelResolution, request = {}) {
114
- const route = config.routes?.[virtualProvider.route];
115
- if (!route) {
116
- throw new ModelResolveError("route_not_found", `Route 不存在: ${virtualProvider.route}`);
117
- }
118
- if (!Array.isArray(route.backends) || route.backends.length !== 1) {
119
- throw new ModelResolveError("invalid_route", "每份配置必须且只能连接一个 upstream");
120
- }
121
-
122
- const backend = route.backends[0];
123
- const upstream = config.upstreams?.[backend?.upstream];
124
- const profile = modelResolution.profile;
125
- const modelBindings = profile?.upstreams || {};
126
- if (Object.keys(modelBindings).length > 1) {
127
- throw new ModelResolveError("invalid_model_binding", "每个 Model Profile 只能映射一个 upstream");
128
- }
129
- const modelBinding = modelBindings[backend?.upstream];
130
- const required = requiredCapabilities(request);
131
- const capabilities = effectiveCapabilities(profile, modelBinding);
132
- let reason;
133
- if (!backend || backend.enabled === false || !upstream || upstream.enabled === false) {
134
- reason = "upstream_disabled_or_missing";
135
- } else if (profile && !backend.models?.includes(modelResolution.profileId)) {
136
- reason = "model_not_in_route";
137
- } else if (profile && !modelBinding) {
138
- reason = "model_binding_missing";
139
- } else if (virtualProvider.ingressProtocol !== upstream.protocol) {
140
- reason = "protocol_transform_missing";
141
- } else if (profile &&
142
- (virtualProvider.ingressProtocol !== "anthropic.messages" || profile.capabilities !== undefined || modelBinding?.capabilityOverrides?.length) &&
143
- !hasCapabilities(capabilities, required)) {
144
- reason = "capability_missing";
145
- }
146
- if (reason) {
147
- throw new ModelResolveError(
148
- "no_compatible_upstream",
149
- `当前配置的 upstream 无法处理模型 ${modelResolution.clientModelId}`,
150
- {
151
- profileId: modelResolution.profileId,
152
- rejected: [{ index: 0, reason, ...(reason === "capability_missing"
153
- ? { missing: [...required].filter((item) => !capabilities.has(item)) } : {}) }],
154
- },
155
- );
156
- }
157
- return {
158
- routeId: virtualProvider.route,
159
- upstream,
160
- upstreamModelId: modelBinding?.upstreamModelId || modelResolution.clientModelId,
161
- capabilities: [...capabilities],
162
- };
163
- }
164
-
165
- export function resolveRequest(config, virtualProvider, requestBody = {}) {
166
- const model = resolveModelProfile(config, virtualProvider, requestBody.model);
167
- const backend = selectBackend(config, virtualProvider, model, requestBody);
168
- return { model, ...backend };
169
- }
170
-
171
- export function rewriteModelFields(value, clientModelId, upstreamModelId) {
172
- if (Array.isArray(value)) {
173
- return value.map((item) => rewriteModelFields(item, clientModelId, upstreamModelId));
174
- }
175
- if (!value || typeof value !== "object") return value;
176
- const output = {};
177
- for (const [key, item] of Object.entries(value)) {
178
- if (key === "model" && item === upstreamModelId) {
179
- output[key] = clientModelId;
180
- } else {
181
- output[key] = rewriteModelFields(item, clientModelId, upstreamModelId);
182
- }
183
- }
184
- return output;
185
- }
@@ -1,72 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { execFile } from "node:child_process";
5
- import { promisify } from "node:util";
6
-
7
- const execute = promisify(execFile);
8
- export const powershell = path.win32.join(
9
- process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe",
10
- );
11
-
12
- export async function processStartTime(pid, {
13
- platform = process.platform, readFile = fs.readFile, run = execute,
14
- } = {}) {
15
- if (!Number.isSafeInteger(pid) || pid <= 0) return null;
16
- try {
17
- if (platform === "linux") {
18
- const [stat, boot] = await Promise.all([
19
- readFile(`/proc/${pid}/stat`, "utf8"),
20
- readFile("/proc/sys/kernel/random/boot_id", "utf8"),
21
- ]);
22
- const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
23
- if (!/^\d+$/.test(fields[19]) || !/^[\da-f-]{36}$/i.test(boot.trim())) return null;
24
- return `linux:${boot.trim()}:${fields[19]}`;
25
- }
26
- const options = { timeout: 5000, maxBuffer: 4096, windowsHide: true };
27
- if (platform === "darwin") {
28
- const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
29
- ...options, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
30
- });
31
- const value = stdout.trim().replace(/\s+/g, " ");
32
- return /^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/.test(value)
33
- ? `darwin:${value}` : null;
34
- }
35
- if (platform === "win32") {
36
- const { stdout } = await run(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
37
- `$ErrorActionPreference = 'Stop'; (Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks.ToString()`,
38
- ], options);
39
- return /^\d{17,}$/.test(stdout.trim()) ? `win32:${stdout.trim()}` : null;
40
- }
41
- } catch {
42
- // Query failures do not prove that a PID is dead or still has its old owner.
43
- }
44
- return null;
45
- }
46
-
47
- export async function inspectProcess(identity, {
48
- kill = process.kill, readStartTime = processStartTime, hostname = os.hostname(),
49
- } = {}) {
50
- const pid = identity?.pid;
51
- if (!Number.isSafeInteger(pid) || pid <= 0 || (identity.hostname && identity.hostname !== hostname)) {
52
- return "unknown";
53
- }
54
- try {
55
- kill(pid, 0);
56
- } catch (error) {
57
- if (error.code === "ESRCH") return "dead";
58
- if (error.code !== "EPERM") return "unknown";
59
- }
60
- if (typeof identity.startTime !== "string" || !identity.startTime) return "unknown";
61
- const current = await readStartTime(pid);
62
- if (!current) return "unknown";
63
- if (/^(linux|darwin|win32):/.test(identity.startTime)) {
64
- if (identity.startTime.split(":")[0] !== current.split(":")[0]) return "unknown";
65
- return current === identity.startTime ? "alive" : "dead";
66
- }
67
- // Old Linux ticks can disprove ownership, but without a boot ID cannot prove it.
68
- if (/^\d+$/.test(identity.startTime) && current.startsWith("linux:") && current.split(":").at(-1) !== identity.startTime) {
69
- return "dead";
70
- }
71
- return "unknown";
72
- }