cabletidy 0.1.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/src/config.mjs ADDED
@@ -0,0 +1,361 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { normalizeConfigurationIdentities } from "../web/config-identity.js";
6
+
7
+ const DEFAULT_HOME = process.env.CABLETIDY_HOME || path.join(os.homedir(), ".cabletidy");
8
+ const SECRET_FIELD_NAMES = new Set([
9
+ "secret",
10
+ "apiKey",
11
+ "api_key",
12
+ "token",
13
+ "accessToken",
14
+ "access_token",
15
+ "refreshToken",
16
+ "refresh_token",
17
+ "clientSecret",
18
+ "client_secret",
19
+ "password",
20
+ "privateKey",
21
+ "private_key",
22
+ ]);
23
+
24
+ export function getPaths(home = DEFAULT_HOME) {
25
+ return {
26
+ home,
27
+ config: path.join(home, "config.json"),
28
+ secrets: path.join(home, "secrets.json"),
29
+ runtime: path.join(home, "runtime.json"),
30
+ backups: path.join(home, "backups"),
31
+ };
32
+ }
33
+
34
+ export function defaultConfig() {
35
+ return {
36
+ version: 1,
37
+ revision: 0,
38
+ daemon: {},
39
+ web: {
40
+ enabled: true,
41
+ listenHost: "127.0.0.1",
42
+ port: 43100,
43
+ },
44
+ upstreams: {},
45
+ models: {},
46
+ routes: {},
47
+ virtualProviders: {},
48
+ bindings: {},
49
+ };
50
+ }
51
+
52
+ function isRecord(value) {
53
+ return value && typeof value === "object" && !Array.isArray(value);
54
+ }
55
+
56
+ function mergeRecord(base, value) {
57
+ if (!isRecord(value)) return base;
58
+ for (const [key, item] of Object.entries(value)) {
59
+ if (isRecord(item) && isRecord(base[key])) {
60
+ mergeRecord(base[key], item);
61
+ } else {
62
+ base[key] = item;
63
+ }
64
+ }
65
+ return base;
66
+ }
67
+
68
+ export function normalizeConfig(input = {}) {
69
+ const config = mergeRecord(defaultConfig(), structuredClone(input));
70
+ config.version = Number(config.version) || 1;
71
+ config.revision = Number(config.revision) || 0;
72
+ for (const key of ["upstreams", "models", "routes", "virtualProviders", "bindings"]) {
73
+ if (!isRecord(config[key])) config[key] = {};
74
+ }
75
+ // The Web console is a loopback-only local control surface; old session
76
+ // lifetime settings are no longer part of the effective configuration.
77
+ delete config.web.sessionTtlSeconds;
78
+ if (isRecord(config.daemon)) delete config.daemon.proxyPortRange;
79
+ migratePrototypeCodexFields(config);
80
+ for (const provider of Object.values(config.virtualProviders)) {
81
+ if (isRecord(provider)) {
82
+ delete provider.localAuth;
83
+ delete provider.listenHost;
84
+ delete provider.listenPort;
85
+ }
86
+ }
87
+ for (const binding of Object.values(config.bindings)) {
88
+ if (isRecord(binding?.codex)) delete binding.codex.localEnvKey;
89
+ if (isRecord(binding?.claude)) delete binding.claude.authEnv;
90
+ }
91
+ normalizeConfigurationIdentities(config);
92
+ return config;
93
+ }
94
+
95
+ function migratePrototypeCodexFields(config) {
96
+ // Read legacy prototype keys once, translate useful values, and discard
97
+ // profile-file metadata before the config reaches runtime or disk.
98
+ for (const upstream of Object.values(config.upstreams)) {
99
+ if (!isRecord(upstream)) continue;
100
+ // These policy placeholders never affected runtime behavior.
101
+ for (const field of ["requestMaxRetries", "streamMaxRetries", "streamIdleTimeoutMs", "requiresOpenaiAuth", "supportsWebsockets"]) {
102
+ delete upstream[field];
103
+ }
104
+ if (!upstream.integration && upstream.providerFormat === "codex.toml.v1") {
105
+ upstream.integration = "codex-native-provider";
106
+ }
107
+ delete upstream.envKey;
108
+ delete upstream.codexNative;
109
+ delete upstream.providerFormat;
110
+ delete upstream.codexToml;
111
+ }
112
+
113
+ for (const model of Object.values(config.models)) {
114
+ if (!isRecord(model)) continue;
115
+ const legacy = isRecord(model.targetOverrides?.codex)
116
+ ? model.targetOverrides.codex
117
+ : isRecord(model.legacyCodex)
118
+ ? model.legacyCodex
119
+ : null;
120
+ if (isRecord(legacy)) {
121
+ if (model.clientModelId === undefined && legacy.model) {
122
+ model.clientModelId = legacy.model;
123
+ }
124
+ if (model.contextWindow === undefined && legacy.modelContextWindow !== undefined) {
125
+ model.contextWindow = legacy.modelContextWindow;
126
+ }
127
+ if (model.compact === undefined && legacy.modelAutoCompactTokenLimit !== undefined) {
128
+ model.compact = {
129
+ strategy: "auto",
130
+ tokenLimit: legacy.modelAutoCompactTokenLimit,
131
+ };
132
+ }
133
+ if (model.personality === undefined && legacy.personality) {
134
+ model.personality = legacy.personality;
135
+ }
136
+ }
137
+ // Profile IDs, profile file paths, and other target-only fields belonged
138
+ // to the old prototype. They are migration input, not CableTidy state.
139
+ delete model.legacyCodex;
140
+ if (isRecord(model.targetOverrides)) {
141
+ delete model.targetOverrides.codex;
142
+ if (Object.keys(model.targetOverrides).length === 0) delete model.targetOverrides;
143
+ }
144
+ // Reasoning effort is selected by the CLI request, not fixed on a model.
145
+ delete model.reasoning;
146
+ }
147
+
148
+ for (const binding of Object.values(config.bindings)) {
149
+ if (!isRecord(binding)) continue;
150
+ if (!binding.integration && binding.providerFormat === "codex.toml.v1") {
151
+ binding.integration = "codex-native-provider";
152
+ }
153
+ if (binding.targetFormat === "codex.toml.v1") {
154
+ binding.targetFormat = "codex.config.toml.v1";
155
+ }
156
+ delete binding.providerFormat;
157
+ if (isRecord(binding.codex)) {
158
+ for (const key of ["profileFiles", "sourceUpstream", "sourceProviderId"]) {
159
+ delete binding.codex[key];
160
+ }
161
+ delete binding.codex.providerId;
162
+ }
163
+ delete binding.legacyCodex;
164
+ }
165
+ }
166
+
167
+ export function publicConfig(config) {
168
+ const copy = structuredClone(normalizeConfig(config));
169
+ redactSecretFields(copy);
170
+ return copy;
171
+ }
172
+
173
+ export function stripSecretFields(value) {
174
+ const copy = structuredClone(value);
175
+ redactSecretFields(copy);
176
+ return copy;
177
+ }
178
+
179
+ export async function ensureStore(paths = getPaths()) {
180
+ await fs.mkdir(paths.home, { recursive: true, mode: 0o700 });
181
+ await fs.mkdir(paths.backups, { recursive: true, mode: 0o700 });
182
+ try {
183
+ await fs.access(paths.config);
184
+ } catch {
185
+ await writeJsonAtomic(paths.config, defaultConfig(), 0o600);
186
+ }
187
+ try {
188
+ await fs.access(paths.secrets);
189
+ } catch {
190
+ await writeJsonAtomic(paths.secrets, {}, 0o600);
191
+ }
192
+ }
193
+
194
+ export async function loadConfig(paths = getPaths()) {
195
+ await ensureStore(paths);
196
+ const raw = await fs.readFile(paths.config, "utf8");
197
+ return normalizeConfig(JSON.parse(raw));
198
+ }
199
+
200
+ export async function loadSecrets(paths = getPaths()) {
201
+ await ensureStore(paths);
202
+ const raw = await fs.readFile(paths.secrets, "utf8");
203
+ const parsed = JSON.parse(raw);
204
+ return isRecord(parsed) ? parsed : {};
205
+ }
206
+
207
+ export async function writeJsonAtomic(file, value, mode = 0o600) {
208
+ const directory = path.dirname(file);
209
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
210
+ const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
211
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
212
+ await fs.chmod(temporary, mode);
213
+ await fs.rename(temporary, file);
214
+ }
215
+
216
+ export async function saveConfig(config, paths = getPaths()) {
217
+ await writeJsonAtomic(paths.config, normalizeConfig(config), 0o600);
218
+ }
219
+
220
+ export async function saveSecrets(secrets, paths = getPaths()) {
221
+ await writeJsonAtomic(paths.secrets, secrets, 0o600);
222
+ }
223
+
224
+ export async function backupFile(file, backupsDirectory, label) {
225
+ try {
226
+ const contents = await fs.readFile(file);
227
+ const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
228
+ const backup = path.join(backupsDirectory, `${stamp}-${label}`);
229
+ await fs.writeFile(backup, contents, { mode: 0o600 });
230
+ return backup;
231
+ } catch (error) {
232
+ if (error.code === "ENOENT") return null;
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ export function secretRefForUpstream(id) {
238
+ return `secret://upstreams/${id}`;
239
+ }
240
+
241
+ export function resolveSecret(reference, secrets = {}) {
242
+ if (typeof reference !== "string" || !reference) return "";
243
+ return secrets[reference] || "";
244
+ }
245
+
246
+ export function resolveUpstreamSecret(upstream, secrets = {}) {
247
+ return resolveSecret(upstream?.secretRef, secrets);
248
+ }
249
+
250
+ export function applySecretPayload(config, secrets, payload = {}) {
251
+ const nextSecrets = { ...secrets };
252
+ const upstreamSecrets = isRecord(payload.upstreamSecrets) ? payload.upstreamSecrets : {};
253
+ for (const [id, value] of Object.entries(upstreamSecrets)) {
254
+ if (typeof value !== "string" || !value.trim()) continue;
255
+ const upstream = config.upstreams[id];
256
+ if (!upstream) continue;
257
+ upstream.secretRef ||= secretRefForUpstream(id);
258
+ nextSecrets[upstream.secretRef] = value.trim();
259
+ }
260
+ return nextSecrets;
261
+ }
262
+
263
+ export async function writeRuntimeInfo(info, paths = getPaths()) {
264
+ await writeJsonAtomic(paths.runtime, info, 0o600);
265
+ }
266
+
267
+ export async function readRuntimeInfo(paths = getPaths()) {
268
+ try {
269
+ return JSON.parse(await fs.readFile(paths.runtime, "utf8"));
270
+ } catch (error) {
271
+ if (error.code === "ENOENT") return null;
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Return a redacted, path-oriented view of the effective configuration change.
278
+ * Secrets are not part of config, and revision is metadata rather than behavior.
279
+ */
280
+ export function computeConfigDiff(beforeInput = {}, afterInput = {}) {
281
+ const before = normalizeConfig(beforeInput);
282
+ const after = normalizeConfig(afterInput);
283
+ const added = [];
284
+ const removed = [];
285
+ const changed = [];
286
+
287
+ diffValues(before, after, "", { added, removed, changed });
288
+
289
+ const roots = new Set(
290
+ [...added, ...removed, ...changed]
291
+ .map((item) => item.path.split(".")[0])
292
+ .filter(Boolean),
293
+ );
294
+ return {
295
+ added,
296
+ removed,
297
+ changed,
298
+ total: added.length + removed.length + changed.length,
299
+ affected: [...roots],
300
+ };
301
+ }
302
+
303
+ function diffValues(before, after, currentPath, output) {
304
+ if (currentPath === "revision") return;
305
+
306
+ if (before === undefined) {
307
+ output.added.push({ path: currentPath, value: after });
308
+ return;
309
+ }
310
+ if (after === undefined) {
311
+ output.removed.push({ path: currentPath, value: before });
312
+ return;
313
+ }
314
+
315
+ if (isRecord(before) && isRecord(after)) {
316
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
317
+ for (const key of [...keys].sort()) {
318
+ diffValues(
319
+ before[key],
320
+ after[key],
321
+ currentPath ? `${currentPath}.${key}` : key,
322
+ output,
323
+ );
324
+ }
325
+ return;
326
+ }
327
+
328
+ if (!sameValue(before, after)) {
329
+ output.changed.push({ path: currentPath, before, after });
330
+ }
331
+ }
332
+
333
+ function redactSecretFields(value) {
334
+ if (Array.isArray(value)) {
335
+ for (const item of value) redactSecretFields(item);
336
+ return;
337
+ }
338
+ if (!isRecord(value)) return;
339
+ for (const [key, item] of Object.entries(value)) {
340
+ if (SECRET_FIELD_NAMES.has(key)) {
341
+ delete value[key];
342
+ continue;
343
+ }
344
+ redactSecretFields(item);
345
+ }
346
+ }
347
+
348
+ function sameValue(left, right) {
349
+ if (Object.is(left, right)) return true;
350
+ if (Array.isArray(left) && Array.isArray(right)) {
351
+ if (left.length !== right.length) return false;
352
+ return left.every((value, index) => sameValue(value, right[index]));
353
+ }
354
+ if (isRecord(left) && isRecord(right)) {
355
+ const leftKeys = Object.keys(left);
356
+ const rightKeys = Object.keys(right);
357
+ if (leftKeys.length !== rightKeys.length) return false;
358
+ return leftKeys.every((key) => Object.hasOwn(right, key) && sameValue(left[key], right[key]));
359
+ }
360
+ return false;
361
+ }
@@ -0,0 +1,187 @@
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], matchedBy: "id" };
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, matchedBy: "alias" };
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, matchedBy: "passthrough" };
67
+ }
68
+
69
+ return {
70
+ clientModelId: requestedModel || clientModelIdForProfile(match.profileId, match.profile),
71
+ profileId: match.profileId,
72
+ profile: match.profile,
73
+ matchedBy: match.matchedBy,
74
+ };
75
+ }
76
+
77
+ export function effectiveCapabilities(profile, binding) {
78
+ const capabilities = new Set(profile?.capabilities || []);
79
+ for (const item of binding?.capabilityOverrides || []) {
80
+ if (item.startsWith("-")) capabilities.delete(item.slice(1));
81
+ else if (item.startsWith("+")) capabilities.add(item.slice(1));
82
+ else capabilities.add(item);
83
+ }
84
+ if (profile?.codex?.metadataMode === "override" &&
85
+ profile.codex.inputModalities && !profile.codex.inputModalities.includes("image")) {
86
+ capabilities.delete("vision");
87
+ }
88
+ return capabilities;
89
+ }
90
+
91
+ function requiredCapabilities(request = {}) {
92
+ const required = new Set();
93
+ if (request.stream) required.add("streaming");
94
+ if (Array.isArray(request.tools) && request.tools.length) required.add("tools");
95
+ if (request.tool_choice) required.add("tools");
96
+ if (request.parallel_tool_calls) required.add("parallel_tool_calls");
97
+ if (request.reasoning) required.add("reasoning");
98
+ if (request.thinking && request.thinking.type !== "disabled") required.add("reasoning");
99
+ if (request.images || request.image_input || containsImage(request.input) || containsImage(request.messages)) required.add("vision");
100
+ return required;
101
+ }
102
+
103
+ function containsImage(value) {
104
+ if (Array.isArray(value)) return value.some(containsImage);
105
+ if (!value || typeof value !== "object") return false;
106
+ if (["input_image", "image", "image_url"].includes(value.type)) return true;
107
+ return containsImage(value.content);
108
+ }
109
+
110
+ function hasCapabilities(capabilities, required) {
111
+ return [...required].every((item) => capabilities.has(item));
112
+ }
113
+
114
+ export function selectBackend(config, virtualProvider, modelResolution, request = {}) {
115
+ const route = config.routes?.[virtualProvider.route];
116
+ if (!route) {
117
+ throw new ModelResolveError("route_not_found", `Route 不存在: ${virtualProvider.route}`);
118
+ }
119
+ if (!Array.isArray(route.backends) || route.backends.length !== 1) {
120
+ throw new ModelResolveError("invalid_route", "每份配置必须且只能连接一个 upstream");
121
+ }
122
+
123
+ const backend = route.backends[0];
124
+ const upstream = config.upstreams?.[backend?.upstream];
125
+ const profile = modelResolution.profile;
126
+ const modelBindings = profile?.upstreams || {};
127
+ if (Object.keys(modelBindings).length > 1) {
128
+ throw new ModelResolveError("invalid_model_binding", "每个 Model Profile 只能映射一个 upstream");
129
+ }
130
+ const modelBinding = modelBindings[backend?.upstream];
131
+ const required = requiredCapabilities(request);
132
+ const capabilities = effectiveCapabilities(profile, modelBinding);
133
+ let reason;
134
+ if (!backend || backend.enabled === false || !upstream || upstream.enabled === false) {
135
+ reason = "upstream_disabled_or_missing";
136
+ } else if (profile && !backend.models?.includes(modelResolution.profileId)) {
137
+ reason = "model_not_in_route";
138
+ } else if (profile && !modelBinding) {
139
+ reason = "model_binding_missing";
140
+ } else if (virtualProvider.ingressProtocol !== upstream.protocol) {
141
+ reason = "protocol_transform_missing";
142
+ } else if (profile && !hasCapabilities(capabilities, required)) {
143
+ reason = "capability_missing";
144
+ }
145
+ if (reason) {
146
+ throw new ModelResolveError(
147
+ "no_compatible_upstream",
148
+ `当前配置的 upstream 无法处理模型 ${modelResolution.clientModelId}`,
149
+ {
150
+ profileId: modelResolution.profileId,
151
+ rejected: [{ index: 0, reason, ...(reason === "capability_missing"
152
+ ? { missing: [...required].filter((item) => !capabilities.has(item)) } : {}) }],
153
+ },
154
+ );
155
+ }
156
+ return {
157
+ routeId: virtualProvider.route,
158
+ backend,
159
+ backendIndex: 0,
160
+ upstream,
161
+ upstreamModelId: modelBinding?.upstreamModelId || modelResolution.clientModelId,
162
+ capabilities: [...capabilities],
163
+ rejected: [],
164
+ };
165
+ }
166
+
167
+ export function resolveRequest(config, virtualProvider, requestBody = {}) {
168
+ const model = resolveModelProfile(config, virtualProvider, requestBody.model);
169
+ const backend = selectBackend(config, virtualProvider, model, requestBody);
170
+ return { model, ...backend };
171
+ }
172
+
173
+ export function rewriteModelFields(value, clientModelId, upstreamModelId) {
174
+ if (Array.isArray(value)) {
175
+ return value.map((item) => rewriteModelFields(item, clientModelId, upstreamModelId));
176
+ }
177
+ if (!value || typeof value !== "object") return value;
178
+ const output = {};
179
+ for (const [key, item] of Object.entries(value)) {
180
+ if (key === "model" && item === upstreamModelId) {
181
+ output[key] = clientModelId;
182
+ } else {
183
+ output[key] = rewriteModelFields(item, clientModelId, upstreamModelId);
184
+ }
185
+ }
186
+ return output;
187
+ }