switchboard-fyi 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/LICENSE +21 -0
- package/README.md +538 -0
- package/bin/switchboard-gateway.mjs +5543 -0
- package/bin/switchboard-inspector.mjs +814 -0
- package/bin/switchboard.mjs +6936 -0
- package/docs/codex-subscription-provider-proxy.md +133 -0
- package/docs/known-limitations.md +69 -0
- package/docs/mvp-usage.md +207 -0
- package/docs/routing-api.md +197 -0
- package/docs/smoke-test.md +190 -0
- package/lib/switchboard-core.mjs +779 -0
- package/package.json +50 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const DIFFICULTY_LEVELS = [1, 2, 3, 4, 5];
|
|
7
|
+
export const MODEL_TIERS = ["lower", "mid", "best"];
|
|
8
|
+
|
|
9
|
+
function codexDifficultyMap() {
|
|
10
|
+
return {
|
|
11
|
+
1: { model: "gpt-5.4-mini", reasoningEffort: "low" },
|
|
12
|
+
2: { model: "gpt-5.4-mini", reasoningEffort: "medium" },
|
|
13
|
+
3: { model: "gpt-5.4", reasoningEffort: "medium" },
|
|
14
|
+
4: { model: "gpt-5.5", reasoningEffort: "medium" },
|
|
15
|
+
5: { model: "gpt-5.5", reasoningEffort: "xhigh" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function claudeDifficultyMap() {
|
|
20
|
+
return {
|
|
21
|
+
1: { model: "claude-haiku-4-5" },
|
|
22
|
+
2: { model: "claude-sonnet-4-6", effort: "low" },
|
|
23
|
+
3: { model: "claude-sonnet-4-6", effort: "medium" },
|
|
24
|
+
4: { model: "claude-opus-4-7", effort: "medium" },
|
|
25
|
+
5: { model: "claude-opus-4-7", effort: "xhigh" },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_CONFIG = {
|
|
30
|
+
schemaVersion: 2,
|
|
31
|
+
routing: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
},
|
|
34
|
+
updates: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
checkIntervalHours: 24,
|
|
37
|
+
packageName: "switchboard-fyi",
|
|
38
|
+
registry: "https://registry.npmjs.org",
|
|
39
|
+
releaseNotesUrl: "https://switchboard.fyi/release/latest",
|
|
40
|
+
},
|
|
41
|
+
componentStatus: {
|
|
42
|
+
enabled: true,
|
|
43
|
+
url: "",
|
|
44
|
+
checkIntervalMinutes: 30,
|
|
45
|
+
timeoutMs: 900,
|
|
46
|
+
staleAfterHours: 24,
|
|
47
|
+
overrides: {},
|
|
48
|
+
},
|
|
49
|
+
api: {
|
|
50
|
+
baseUrl: "https://api.switchboard.fyi",
|
|
51
|
+
appUrl: "https://www.switchboard.fyi",
|
|
52
|
+
loginTimeoutSeconds: 300,
|
|
53
|
+
},
|
|
54
|
+
safety: {
|
|
55
|
+
routeBreakers: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
dashboard: {
|
|
60
|
+
intervalMs: 2000,
|
|
61
|
+
},
|
|
62
|
+
sessionReceipt: {
|
|
63
|
+
enabled: false,
|
|
64
|
+
},
|
|
65
|
+
diagnostics: {
|
|
66
|
+
errorReporting: {
|
|
67
|
+
enabled: true,
|
|
68
|
+
timeoutMs: 700,
|
|
69
|
+
stackLines: 16,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
logging: {
|
|
73
|
+
redactPrompts: false,
|
|
74
|
+
promptPreviewChars: 240,
|
|
75
|
+
maxLogBytes: 5_000_000,
|
|
76
|
+
maxLogFiles: 3,
|
|
77
|
+
},
|
|
78
|
+
router: {
|
|
79
|
+
provider: "switchboard",
|
|
80
|
+
timeoutMs: 2500,
|
|
81
|
+
upstreamIdleTimeoutMs: 600000,
|
|
82
|
+
routingTextChars: 3000,
|
|
83
|
+
snippetChars: 900,
|
|
84
|
+
},
|
|
85
|
+
codex: {
|
|
86
|
+
port: 8789,
|
|
87
|
+
difficultyMap: codexDifficultyMap(),
|
|
88
|
+
},
|
|
89
|
+
claude: {
|
|
90
|
+
port: 8787,
|
|
91
|
+
difficultyMap: claudeDifficultyMap(),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const MODEL_CATALOG = {
|
|
96
|
+
schemaVersion: 2,
|
|
97
|
+
catalogVersion: "2026-05-23",
|
|
98
|
+
harnesses: {
|
|
99
|
+
codex: {
|
|
100
|
+
label: "Codex",
|
|
101
|
+
ownedBy: "openai",
|
|
102
|
+
models: [
|
|
103
|
+
{
|
|
104
|
+
id: "gpt-5.5",
|
|
105
|
+
ownedBy: "openai",
|
|
106
|
+
note: "Flagship OpenAI model for complex reasoning and coding.",
|
|
107
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
108
|
+
pricingUsdPerMillionTokens: { input: 5, cachedInput: 0.5, output: 30 },
|
|
109
|
+
pricingRules: {
|
|
110
|
+
longContext: { inputTokensOver: 272_000, inputMultiplier: 2, outputMultiplier: 1.5 },
|
|
111
|
+
},
|
|
112
|
+
maxInputTokens: 1_000_000,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "gpt-5.4",
|
|
116
|
+
ownedBy: "openai",
|
|
117
|
+
note: "More affordable OpenAI model for coding and professional work.",
|
|
118
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
119
|
+
pricingUsdPerMillionTokens: { input: 2.5, cachedInput: 0.25, output: 15 },
|
|
120
|
+
pricingRules: {
|
|
121
|
+
longContext: { inputTokensOver: 272_000, inputMultiplier: 2, outputMultiplier: 1.5 },
|
|
122
|
+
},
|
|
123
|
+
maxInputTokens: 1_000_000,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "gpt-5.4-mini",
|
|
127
|
+
ownedBy: "openai",
|
|
128
|
+
note: "Fast lower-cost OpenAI tier for well-scoped coding work.",
|
|
129
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
130
|
+
pricingUsdPerMillionTokens: { input: 0.75, cachedInput: 0.075, output: 4.5 },
|
|
131
|
+
maxInputTokens: 400_000,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
claude: {
|
|
136
|
+
label: "Claude Code",
|
|
137
|
+
ownedBy: "anthropic",
|
|
138
|
+
models: [
|
|
139
|
+
{
|
|
140
|
+
id: "claude-opus-4-7",
|
|
141
|
+
ownedBy: "anthropic",
|
|
142
|
+
aliases: ["opus", "opus[1m]", "claude-opus-4-7[1m]"],
|
|
143
|
+
note: "Most capable generally available Claude model for complex reasoning and agentic coding.",
|
|
144
|
+
effortLevels: ["low", "medium", "high", "xhigh", "max"],
|
|
145
|
+
pricingUsdPerMillionTokens: { input: 5, cachedInput: 0.5, output: 25 },
|
|
146
|
+
maxInputTokens: 1_000_000,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "claude-sonnet-4-6",
|
|
150
|
+
ownedBy: "anthropic",
|
|
151
|
+
aliases: ["sonnet", "sonnet[1m]", "claude-sonnet-4-6[1m]"],
|
|
152
|
+
note: "Best speed/intelligence balance in the current Claude family.",
|
|
153
|
+
effortLevels: ["low", "medium", "high", "max"],
|
|
154
|
+
pricingUsdPerMillionTokens: { input: 3, cachedInput: 0.3, output: 15 },
|
|
155
|
+
maxInputTokens: 1_000_000,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "claude-haiku-4-5",
|
|
159
|
+
ownedBy: "anthropic",
|
|
160
|
+
aliases: ["haiku", "claude-haiku-4-5-20251001"],
|
|
161
|
+
canonicalId: "claude-haiku-4-5-20251001",
|
|
162
|
+
note: "Fastest current Claude model; alias for claude-haiku-4-5-20251001.",
|
|
163
|
+
effortLevels: [],
|
|
164
|
+
pricingUsdPerMillionTokens: { input: 1, cachedInput: 0.1, output: 5 },
|
|
165
|
+
maxInputTokens: 200_000,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function baseDifficultyMap(harness) {
|
|
173
|
+
return harness === "claude" ? claudeDifficultyMap() : codexDifficultyMap();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clone(value) {
|
|
177
|
+
return JSON.parse(JSON.stringify(value));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function switchboardHome() {
|
|
181
|
+
return process.env.SWITCHBOARD_HOME || path.join(os.homedir(), ".switchboard");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function configPath() {
|
|
185
|
+
return path.join(switchboardHome(), "config.json");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function ensureHome() {
|
|
189
|
+
fs.mkdirSync(switchboardHome(), { recursive: true, mode: 0o700 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function tempPath(file) {
|
|
193
|
+
return `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isPlainObject(value) {
|
|
197
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function mergeConfig(base, override) {
|
|
201
|
+
const output = { ...base };
|
|
202
|
+
for (const [key, value] of Object.entries(override || {})) {
|
|
203
|
+
if (isPlainObject(value) && isPlainObject(output[key])) output[key] = mergeConfig(output[key], value);
|
|
204
|
+
else output[key] = value;
|
|
205
|
+
}
|
|
206
|
+
return output;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function stringValue(value) {
|
|
210
|
+
return typeof value === "string" ? value.trim() : "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function canonicalDifficultyEntry(entry = {}) {
|
|
214
|
+
const raw = isPlainObject(entry) ? entry : {};
|
|
215
|
+
return {
|
|
216
|
+
model: stringValue(raw.model),
|
|
217
|
+
reasoningEffort: stringValue(raw.reasoningEffort || raw.reasoning_effort),
|
|
218
|
+
effort: stringValue(raw.effort),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function catalogEntryForHarnessModel(harness, model) {
|
|
223
|
+
const options = MODEL_CATALOG.harnesses[harness]?.models || [];
|
|
224
|
+
return (
|
|
225
|
+
options.find((entry) =>
|
|
226
|
+
[entry.id, entry.canonicalId, ...(entry.aliases || [])]
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.includes(model),
|
|
229
|
+
) || null
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function supportedHarnessEfforts(harness, model) {
|
|
234
|
+
const entry = catalogEntryForHarnessModel(harness, model);
|
|
235
|
+
if (!entry) return [];
|
|
236
|
+
return harness === "codex"
|
|
237
|
+
? [...(entry.reasoningEfforts || [])]
|
|
238
|
+
: [...(entry.effortLevels || [])];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sanitizeDifficultyEntry(entry, harness, fallbackEntry = {}) {
|
|
242
|
+
const next = canonicalDifficultyEntry(entry);
|
|
243
|
+
const fallback = canonicalDifficultyEntry(fallbackEntry);
|
|
244
|
+
const supportedEfforts = supportedHarnessEfforts(harness, next.model);
|
|
245
|
+
const reasoningEffort =
|
|
246
|
+
next.reasoningEffort ||
|
|
247
|
+
(next.model === fallback.model ? fallback.reasoningEffort : "");
|
|
248
|
+
const effort =
|
|
249
|
+
next.effort ||
|
|
250
|
+
(next.model === fallback.model ? fallback.effort : "");
|
|
251
|
+
return {
|
|
252
|
+
model: next.model,
|
|
253
|
+
reasoningEffort:
|
|
254
|
+
harness === "codex" && supportedEfforts.length
|
|
255
|
+
? supportedEfforts.includes(reasoningEffort)
|
|
256
|
+
? reasoningEffort
|
|
257
|
+
: supportedEfforts[0]
|
|
258
|
+
: "",
|
|
259
|
+
effort:
|
|
260
|
+
harness === "claude" && supportedEfforts.length
|
|
261
|
+
? supportedEfforts.includes(effort)
|
|
262
|
+
? effort
|
|
263
|
+
: supportedEfforts[0]
|
|
264
|
+
: "",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeDifficultyMap(rawMap, harness) {
|
|
269
|
+
const base = baseDifficultyMap(harness);
|
|
270
|
+
const next = {};
|
|
271
|
+
for (const level of DIFFICULTY_LEVELS) {
|
|
272
|
+
const baseEntry = canonicalDifficultyEntry(base[level]);
|
|
273
|
+
const rawEntry = canonicalDifficultyEntry(rawMap?.[level] || rawMap?.[String(level)]);
|
|
274
|
+
next[level] = sanitizeDifficultyEntry({
|
|
275
|
+
model: rawEntry.model || baseEntry.model,
|
|
276
|
+
reasoningEffort: rawEntry.reasoningEffort,
|
|
277
|
+
effort: rawEntry.effort,
|
|
278
|
+
}, harness, baseEntry);
|
|
279
|
+
}
|
|
280
|
+
return next;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function difficultyMapToTierModels(difficultyMap) {
|
|
284
|
+
return {
|
|
285
|
+
lower: canonicalDifficultyEntry(difficultyMap[1]),
|
|
286
|
+
mid: canonicalDifficultyEntry(difficultyMap[3]),
|
|
287
|
+
best: canonicalDifficultyEntry(difficultyMap[5]),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function legacyHarnessShape(harnessConfig, harness) {
|
|
292
|
+
const difficultyMap = normalizeDifficultyMap(harnessConfig?.difficultyMap, harness);
|
|
293
|
+
const tiers = difficultyMapToTierModels(difficultyMap);
|
|
294
|
+
const next = {
|
|
295
|
+
...harnessConfig,
|
|
296
|
+
difficultyMap,
|
|
297
|
+
models: tiers,
|
|
298
|
+
cheapModel: tiers.lower.model,
|
|
299
|
+
midModel: tiers.mid.model,
|
|
300
|
+
premiumModel: tiers.best.model,
|
|
301
|
+
modelProfile: "",
|
|
302
|
+
mode: "",
|
|
303
|
+
};
|
|
304
|
+
if (harness === "codex") {
|
|
305
|
+
next.reasoningEffortByTier = {
|
|
306
|
+
lower: tiers.lower.reasoningEffort || "",
|
|
307
|
+
mid: tiers.mid.reasoningEffort || "",
|
|
308
|
+
best: tiers.best.reasoningEffort || "",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeConfig(config) {
|
|
315
|
+
const input = isPlainObject(config) ? config : {};
|
|
316
|
+
const merged = mergeConfig(DEFAULT_CONFIG, input?.schemaVersion === 2 ? input : {});
|
|
317
|
+
merged.schemaVersion = 2;
|
|
318
|
+
merged.codex = legacyHarnessShape(merged.codex, "codex");
|
|
319
|
+
merged.claude = legacyHarnessShape(merged.claude, "claude");
|
|
320
|
+
return merged;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function loadConfig() {
|
|
324
|
+
ensureHome();
|
|
325
|
+
try {
|
|
326
|
+
const raw = fs.readFileSync(configPath(), "utf8");
|
|
327
|
+
return normalizeConfig(JSON.parse(raw));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error?.code !== "ENOENT") {
|
|
330
|
+
return { ...clone(DEFAULT_CONFIG), configError: error.message };
|
|
331
|
+
}
|
|
332
|
+
return normalizeConfig(DEFAULT_CONFIG);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function ensureConfigFile() {
|
|
337
|
+
ensureHome();
|
|
338
|
+
const file = configPath();
|
|
339
|
+
let config = loadConfig();
|
|
340
|
+
if (!fs.existsSync(file)) {
|
|
341
|
+
const tmp = tempPath(file);
|
|
342
|
+
fs.writeFileSync(tmp, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, { mode: 0o600 });
|
|
343
|
+
fs.renameSync(tmp, file);
|
|
344
|
+
config = loadConfig();
|
|
345
|
+
} else {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
348
|
+
if (parsed?.schemaVersion !== 2) {
|
|
349
|
+
const tmp = tempPath(file);
|
|
350
|
+
fs.writeFileSync(tmp, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, { mode: 0o600 });
|
|
351
|
+
fs.renameSync(tmp, file);
|
|
352
|
+
config = loadConfig();
|
|
353
|
+
}
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
return config;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function difficultyProfile(config, harness, level) {
|
|
360
|
+
const id = normalizeHarness(harness);
|
|
361
|
+
const safeLevel = Math.max(1, Math.min(5, Number(level) || 1));
|
|
362
|
+
const map = config?.[id]?.difficultyMap || DEFAULT_CONFIG?.[id]?.difficultyMap || {};
|
|
363
|
+
return canonicalDifficultyEntry(map[safeLevel] || map[String(safeLevel)]);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export const HARNESS_REGISTRY = {
|
|
367
|
+
codex: {
|
|
368
|
+
id: "codex",
|
|
369
|
+
label: "Codex",
|
|
370
|
+
aliases: ["codex"],
|
|
371
|
+
},
|
|
372
|
+
claude: {
|
|
373
|
+
id: "claude",
|
|
374
|
+
label: "Claude Code",
|
|
375
|
+
aliases: ["claude", "claude-code", "claude_code", "claude code"],
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const HARNESS_ALIASES = Object.fromEntries(
|
|
380
|
+
Object.values(HARNESS_REGISTRY).flatMap((harness) =>
|
|
381
|
+
harness.aliases.map((alias) => [String(alias).toLowerCase().replace(/_/g, "-").trim(), harness.id]),
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
export function knownHarnesses() {
|
|
386
|
+
return Object.keys(HARNESS_REGISTRY);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function normalizeHarness(value) {
|
|
390
|
+
const normalized = String(value || "").toLowerCase().replace(/_/g, "-").trim();
|
|
391
|
+
return HARNESS_ALIASES[normalized] || null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function harnessLabel(harness) {
|
|
395
|
+
const id = normalizeHarness(harness) || harness;
|
|
396
|
+
return HARNESS_REGISTRY[id]?.label || String(harness || "Unknown");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function harnessRuntimeDir(harness) {
|
|
400
|
+
const id = normalizeHarness(harness);
|
|
401
|
+
if (!id) throw new Error(`Unknown Switchboard harness: ${harness}`);
|
|
402
|
+
return path.join(switchboardHome(), "harnesses", id);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function harnessLogPath(harness, name = "events.jsonl") {
|
|
406
|
+
return path.join(harnessRuntimeDir(harness), name);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function logPath(name) {
|
|
410
|
+
return path.join(switchboardHome(), name);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function healthPath(harness = null) {
|
|
414
|
+
return harness ? path.join(harnessRuntimeDir(harness), "health.json") : path.join(switchboardHome(), "health.json");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function redactPrompt(text, config = loadConfig()) {
|
|
418
|
+
if (!text) return "";
|
|
419
|
+
if (config.logging?.redactPrompts) return "[redacted]";
|
|
420
|
+
return redactSensitiveText(String(text)).slice(0, config.logging?.promptPreviewChars || 240);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function redactSensitiveText(text) {
|
|
424
|
+
return String(text || "")
|
|
425
|
+
.replace(/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9_=-]{12,}\b/g, "[redacted-stripe-key]")
|
|
426
|
+
.replace(/\bwhsec_[A-Za-z0-9_=-]{12,}\b/g, "[redacted-stripe-secret]")
|
|
427
|
+
.replace(/\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted-openai-key]")
|
|
428
|
+
.replace(/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, "[redacted-anthropic-key]")
|
|
429
|
+
.replace(/\bghp_[A-Za-z0-9_]{20,}\b/g, "[redacted-github-token]")
|
|
430
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[redacted-github-token]")
|
|
431
|
+
.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted-jwt]")
|
|
432
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/gi, "Bearer [redacted-token]")
|
|
433
|
+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted-email]");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function redactDiagnosticText(text) {
|
|
437
|
+
return redactSensitiveText(text)
|
|
438
|
+
.replace(
|
|
439
|
+
/\b[A-Za-z_][A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH)[A-Za-z0-9_]*=([^\s]+)/gi,
|
|
440
|
+
(match) => `${match.split("=")[0]}=[redacted]`,
|
|
441
|
+
)
|
|
442
|
+
.replace(/\/Users\/[^/\s]+/g, "~")
|
|
443
|
+
.replace(/\/home\/[^/\s]+/g, "~")
|
|
444
|
+
.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, "~");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function diagnosticText(value, maxChars) {
|
|
448
|
+
return redactDiagnosticText(String(value || "").replace(/\0/g, "").replace(/\s+/g, " ").trim())
|
|
449
|
+
.slice(0, Math.max(0, maxChars));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function diagnosticToken(value, maxChars) {
|
|
453
|
+
return diagnosticText(value, maxChars)
|
|
454
|
+
.replace(/[^A-Za-z0-9._:-]+/g, "_")
|
|
455
|
+
.replace(/^_+|_+$/g, "")
|
|
456
|
+
.slice(0, Math.max(0, maxChars));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function diagnosticStack(error, stackLines) {
|
|
460
|
+
const stack = typeof error?.stack === "string" ? error.stack : "";
|
|
461
|
+
if (!stack) return "";
|
|
462
|
+
return stack
|
|
463
|
+
.split("\n")
|
|
464
|
+
.map((line) => redactDiagnosticText(line).trimEnd())
|
|
465
|
+
.filter(Boolean)
|
|
466
|
+
.slice(0, Math.max(1, Number(stackLines) || 16))
|
|
467
|
+
.join("\n")
|
|
468
|
+
.slice(0, 8000);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function diagnosticReportingEnabled(config = loadConfig()) {
|
|
472
|
+
const override = String(process.env.SWITCHBOARD_ERROR_REPORTING || "").toLowerCase().trim();
|
|
473
|
+
if (["0", "false", "off", "no"].includes(override)) return false;
|
|
474
|
+
if (["1", "true", "on", "yes"].includes(override)) return true;
|
|
475
|
+
return config.diagnostics?.errorReporting?.enabled !== false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function diagnosticApiUrl(value) {
|
|
479
|
+
const raw = String(value || DEFAULT_CONFIG.api.baseUrl || "").trim();
|
|
480
|
+
if (!raw) return "";
|
|
481
|
+
let url;
|
|
482
|
+
try {
|
|
483
|
+
url = new URL(raw);
|
|
484
|
+
} catch {
|
|
485
|
+
return "";
|
|
486
|
+
}
|
|
487
|
+
const host = url.hostname.toLowerCase();
|
|
488
|
+
const local = ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
489
|
+
const allowed = local || host === "api.switchboard.fyi";
|
|
490
|
+
if (process.env.SWITCHBOARD_UNSAFE_API_URL !== "1" && !allowed) return "";
|
|
491
|
+
if (process.env.SWITCHBOARD_UNSAFE_API_URL !== "1" && url.protocol !== "https:" && !(local && url.protocol === "http:")) return "";
|
|
492
|
+
return url.toString().replace(/\/+$/, "");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function diagnosticEventId(source = "cli") {
|
|
496
|
+
const prefix = diagnosticToken(source, 24) || "cli";
|
|
497
|
+
return `${prefix}_err_${Date.now().toString(36)}_${crypto.randomBytes(8).toString("hex")}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function diagnosticPackageInfo() {
|
|
501
|
+
try {
|
|
502
|
+
return JSON.parse(fs.readFileSync(path.resolve(new URL("..", import.meta.url).pathname, "package.json"), "utf8"));
|
|
503
|
+
} catch {
|
|
504
|
+
return { name: "switchboard-fyi", version: "0.0.0" };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function diagnosticErrorName(error) {
|
|
509
|
+
return diagnosticText(error?.name || "Error", 160) || "Error";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function diagnosticErrorCode(error) {
|
|
513
|
+
return diagnosticToken(error?.code || error?.cause?.code || "", 120);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function diagnosticErrorMessage(error) {
|
|
517
|
+
if (error?.message) return diagnosticText(error.message, 1000);
|
|
518
|
+
return diagnosticText(String(error || "Unknown error"), 1000);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizeDiagnosticMessage(message) {
|
|
522
|
+
return String(message || "")
|
|
523
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "[uuid]")
|
|
524
|
+
.replace(/\b\d+\b/g, "[n]")
|
|
525
|
+
.toLowerCase()
|
|
526
|
+
.slice(0, 300);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function diagnosticFingerprint(error, message, stack) {
|
|
530
|
+
const name = diagnosticErrorName(error);
|
|
531
|
+
const code = diagnosticErrorCode(error);
|
|
532
|
+
const firstFrame = String(stack || "").split("\n").find((line) => /\bat\b|\.mjs:|\.js:|\.ts:/.test(line)) || "";
|
|
533
|
+
return crypto
|
|
534
|
+
.createHash("sha256")
|
|
535
|
+
.update([name, code, normalizeDiagnosticMessage(message), firstFrame].join("\n"))
|
|
536
|
+
.digest("hex")
|
|
537
|
+
.slice(0, 24);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function sanitizeDiagnosticJson(value, depth = 0) {
|
|
541
|
+
if (value == null) return null;
|
|
542
|
+
if (typeof value === "boolean") return value;
|
|
543
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
544
|
+
if (typeof value === "string") return diagnosticText(value, 500);
|
|
545
|
+
if (Array.isArray(value)) {
|
|
546
|
+
if (depth >= 4) return [];
|
|
547
|
+
return value.slice(0, 20).map((item) => sanitizeDiagnosticJson(item, depth + 1));
|
|
548
|
+
}
|
|
549
|
+
if (typeof value !== "object") return null;
|
|
550
|
+
if (depth >= 4) return {};
|
|
551
|
+
|
|
552
|
+
const output = {};
|
|
553
|
+
for (const [rawKey, rawEntry] of Object.entries(value).slice(0, 60)) {
|
|
554
|
+
const key = diagnosticText(rawKey, 80);
|
|
555
|
+
if (!key) continue;
|
|
556
|
+
if (/(authorization|api[-_]?key|token|secret|password|credential|cookie)/i.test(key)) {
|
|
557
|
+
output[key] = "[redacted]";
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
output[key] = sanitizeDiagnosticJson(rawEntry, depth + 1);
|
|
561
|
+
}
|
|
562
|
+
return output;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function diagnosticSource(value) {
|
|
566
|
+
const source = String(value || "cli").toLowerCase().replace(/[_\s]+/g, "-").trim();
|
|
567
|
+
if (["cli", "gateway", "inspector"].includes(source)) return source;
|
|
568
|
+
return "cli";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function diagnosticSeverity(value) {
|
|
572
|
+
return String(value || "").toLowerCase() === "fatal" ? "fatal" : "error";
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function diagnosticCommand(value) {
|
|
576
|
+
return diagnosticToken(value || "", 160) || null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function diagnosticHarness(value) {
|
|
580
|
+
return normalizeHarness(value) || null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function ignoredDiagnosticMessage(error) {
|
|
584
|
+
const code = diagnosticErrorCode(error).toLowerCase();
|
|
585
|
+
const text = `${diagnosticErrorName(error)} ${diagnosticErrorMessage(error)}`.toLowerCase();
|
|
586
|
+
if (["insufficient_credits", "missing_cli_token", "invalid_cli_token"].includes(code)) return true;
|
|
587
|
+
return [
|
|
588
|
+
/\binsufficient[_ -]?credits?\b/,
|
|
589
|
+
/\bnot logged in\b/,
|
|
590
|
+
/\brun `?switchboard login`?\b/,
|
|
591
|
+
/\bmissing cli token\b/,
|
|
592
|
+
/\binvalid or expired cli token\b/,
|
|
593
|
+
/\bcli token missing scope\b/,
|
|
594
|
+
/\bunknown (switchboard )?(command|harness|model|difficulty command|status command|auth command|models command)\b/,
|
|
595
|
+
/\bconfig key is required\b/,
|
|
596
|
+
/\btop-?up amount must\b/,
|
|
597
|
+
/\bcould not find the real (codex|claude) binary\b/,
|
|
598
|
+
/\binstall (codex|claude) first\b/,
|
|
599
|
+
/\brefusing (untrusted|unsafe|to bind|to install)\b/,
|
|
600
|
+
/\bwindows browser launch is not available\b/,
|
|
601
|
+
/\bstatus must be one of\b/,
|
|
602
|
+
/\bcomponent is required\b/,
|
|
603
|
+
/\b--real can only be used\b/,
|
|
604
|
+
/\bis not executable\b/,
|
|
605
|
+
].some((pattern) => pattern.test(text));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function isReportableDiagnosticError(error) {
|
|
609
|
+
if (!error) return false;
|
|
610
|
+
if (error.switchboardExpected || error.switchboardFriendly || error.expected) return false;
|
|
611
|
+
if (ignoredDiagnosticMessage(error)) return false;
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function reportDiagnosticError(error, options = {}) {
|
|
616
|
+
try {
|
|
617
|
+
const config = options.config || loadConfig();
|
|
618
|
+
if (!diagnosticReportingEnabled(config)) return { reported: false, reason: "disabled" };
|
|
619
|
+
if (!isReportableDiagnosticError(error)) return { reported: false, reason: "ignored" };
|
|
620
|
+
|
|
621
|
+
const token = typeof options.token === "function" ? options.token() : options.token;
|
|
622
|
+
if (!token) return { reported: false, reason: "missing_token" };
|
|
623
|
+
|
|
624
|
+
const apiUrl = diagnosticApiUrl(options.apiUrl || config.api?.baseUrl);
|
|
625
|
+
if (!apiUrl) return { reported: false, reason: "invalid_api_url" };
|
|
626
|
+
|
|
627
|
+
const pkg = options.packageInfo || diagnosticPackageInfo();
|
|
628
|
+
const stack = diagnosticStack(error, config.diagnostics?.errorReporting?.stackLines);
|
|
629
|
+
const message = diagnosticErrorMessage(error);
|
|
630
|
+
const source = diagnosticSource(options.source);
|
|
631
|
+
const context = { ...(options.context || {}) };
|
|
632
|
+
if (options.phase) context.phase = options.phase;
|
|
633
|
+
const payload = {
|
|
634
|
+
client_event_id: diagnosticEventId(source),
|
|
635
|
+
source,
|
|
636
|
+
command: diagnosticCommand(options.command),
|
|
637
|
+
harness: diagnosticHarness(options.harness),
|
|
638
|
+
severity: diagnosticSeverity(options.severity),
|
|
639
|
+
client_session_id: diagnosticToken(options.sessionId || options.clientSessionId || "", 200) || null,
|
|
640
|
+
error: {
|
|
641
|
+
name: diagnosticErrorName(error),
|
|
642
|
+
code: diagnosticErrorCode(error) || null,
|
|
643
|
+
message,
|
|
644
|
+
stack,
|
|
645
|
+
fingerprint: diagnosticFingerprint(error, message, stack),
|
|
646
|
+
},
|
|
647
|
+
cli: {
|
|
648
|
+
name: diagnosticToken(pkg.name || "switchboard-fyi", 80),
|
|
649
|
+
version: diagnosticToken(options.version || pkg.version || "0.0.0", 80),
|
|
650
|
+
node_version: diagnosticToken(process.version, 80),
|
|
651
|
+
platform: diagnosticToken(process.platform, 80),
|
|
652
|
+
arch: diagnosticToken(process.arch, 80),
|
|
653
|
+
},
|
|
654
|
+
context: sanitizeDiagnosticJson(context),
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const controller = new AbortController();
|
|
658
|
+
const timeoutMs = Math.max(100, Number(options.timeoutMs || config.diagnostics?.errorReporting?.timeoutMs || 700));
|
|
659
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
660
|
+
timeout.unref?.();
|
|
661
|
+
try {
|
|
662
|
+
const response = await fetch(new URL("/v1/cli/error-events", `${apiUrl}/`), {
|
|
663
|
+
method: "POST",
|
|
664
|
+
signal: controller.signal,
|
|
665
|
+
headers: {
|
|
666
|
+
authorization: `Bearer ${token}`,
|
|
667
|
+
"content-type": "application/json",
|
|
668
|
+
},
|
|
669
|
+
body: JSON.stringify(payload),
|
|
670
|
+
});
|
|
671
|
+
return { reported: response.ok, status: response.status };
|
|
672
|
+
} finally {
|
|
673
|
+
clearTimeout(timeout);
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
return { reported: false, reason: "failed" };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function rotateLog(file, config = loadConfig()) {
|
|
681
|
+
const maxBytes = Number(config.logging?.maxLogBytes || 0);
|
|
682
|
+
const maxFiles = Number(config.logging?.maxLogFiles || 0);
|
|
683
|
+
if (!maxBytes || !maxFiles) return;
|
|
684
|
+
try {
|
|
685
|
+
const stat = fs.statSync(file);
|
|
686
|
+
if (stat.size < maxBytes) return;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (error?.code === "ENOENT") return;
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
for (let index = maxFiles - 1; index >= 1; index -= 1) {
|
|
692
|
+
const from = `${file}.${index}`;
|
|
693
|
+
const to = `${file}.${index + 1}`;
|
|
694
|
+
if (fs.existsSync(from)) fs.renameSync(from, to);
|
|
695
|
+
}
|
|
696
|
+
fs.renameSync(file, `${file}.1`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function appendJsonl(file, event, config = loadConfig()) {
|
|
700
|
+
ensureHome();
|
|
701
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
702
|
+
rotateLog(file, config);
|
|
703
|
+
fs.appendFileSync(
|
|
704
|
+
file,
|
|
705
|
+
`${JSON.stringify({
|
|
706
|
+
ts: new Date().toISOString(),
|
|
707
|
+
schemaVersion: 2,
|
|
708
|
+
...event,
|
|
709
|
+
})}\n`,
|
|
710
|
+
{ mode: 0o600 },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function readHealth(harness = null) {
|
|
715
|
+
ensureHome();
|
|
716
|
+
try {
|
|
717
|
+
const raw = fs.readFileSync(healthPath(harness), "utf8");
|
|
718
|
+
const parsed = JSON.parse(raw);
|
|
719
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
720
|
+
} catch {
|
|
721
|
+
return {};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function writeHealth(health, harness = null) {
|
|
726
|
+
ensureHome();
|
|
727
|
+
const file = healthPath(harness);
|
|
728
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
729
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
730
|
+
fs.writeFileSync(tmp, `${JSON.stringify(health || {}, null, 2)}\n`, { mode: 0o600 });
|
|
731
|
+
fs.renameSync(tmp, file);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function harnessHealth(health, harness, now = Date.now()) {
|
|
735
|
+
const entry = health?.[harness] || {};
|
|
736
|
+
return {
|
|
737
|
+
...entry,
|
|
738
|
+
state: entry.state === "observe_only" ? "healthy" : entry.state || "healthy",
|
|
739
|
+
circuitOpen: false,
|
|
740
|
+
openUntil: null,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function clearExpiredHealth(health, now = Date.now()) {
|
|
745
|
+
const next = { ...(health || {}) };
|
|
746
|
+
for (const [harness, entry] of Object.entries(next)) {
|
|
747
|
+
const state = harnessHealth(next, harness, now);
|
|
748
|
+
if (!state.circuitOpen && (entry?.state === "observe_only" || entry?.circuitOpen)) {
|
|
749
|
+
next[harness] = {
|
|
750
|
+
...entry,
|
|
751
|
+
state: "healthy",
|
|
752
|
+
openUntil: null,
|
|
753
|
+
reason: "",
|
|
754
|
+
message: "",
|
|
755
|
+
lastRecoveredAt: new Date(now).toISOString(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return next;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function readJsonl(file) {
|
|
763
|
+
try {
|
|
764
|
+
return fs
|
|
765
|
+
.readFileSync(file, "utf8")
|
|
766
|
+
.split("\n")
|
|
767
|
+
.filter(Boolean)
|
|
768
|
+
.map((line) => {
|
|
769
|
+
try {
|
|
770
|
+
return JSON.parse(line);
|
|
771
|
+
} catch {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
.filter(Boolean);
|
|
776
|
+
} catch {
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
}
|