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,814 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { URL } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
harnessLogPath,
|
|
9
|
+
knownHarnesses,
|
|
10
|
+
logPath,
|
|
11
|
+
normalizeHarness,
|
|
12
|
+
readJsonl,
|
|
13
|
+
switchboardHome,
|
|
14
|
+
} from "../lib/switchboard-core.mjs";
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const host = readFlag("--host", "127.0.0.1");
|
|
18
|
+
const port = Number(readFlag("--port", process.env.PORT || "8799"));
|
|
19
|
+
const maxEvents = Number(readFlag("--max-events", "1000"));
|
|
20
|
+
const requestedHarness = readFlag("--harness", "");
|
|
21
|
+
const scopeHarness = requestedHarness ? normalizeHarness(requestedHarness) : null;
|
|
22
|
+
const unsafeBind = args.includes("--unsafe-bind") || process.env.SWITCHBOARD_UNSAFE_BIND === "1";
|
|
23
|
+
if (requestedHarness && !scopeHarness) throw new Error(`Unknown Switchboard harness: ${requestedHarness}`);
|
|
24
|
+
if (!unsafeBind && !isLoopbackHost(host)) {
|
|
25
|
+
throw new Error("Refusing to bind Switchboard inspector to a non-loopback host. Use --unsafe-bind only for trusted local development.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readFlag(name, fallback) {
|
|
29
|
+
const index = args.indexOf(name);
|
|
30
|
+
if (index === -1 || index === args.length - 1) return fallback;
|
|
31
|
+
return args[index + 1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLoopbackHost(value) {
|
|
35
|
+
return ["127.0.0.1", "::1", "localhost"].includes(String(value || "").toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function send(res, status, body, headers = {}) {
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
"cache-control": "no-store",
|
|
41
|
+
...headers,
|
|
42
|
+
});
|
|
43
|
+
res.end(body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendJson(res, status, body) {
|
|
47
|
+
send(res, status, JSON.stringify(body), { "content-type": "application/json" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decisionIdFor(event) {
|
|
51
|
+
return (
|
|
52
|
+
event.decisionId ||
|
|
53
|
+
event.route?.decisionId ||
|
|
54
|
+
event.routingPacketSummary?.decisionId ||
|
|
55
|
+
event.route?.routingApi?.decisionId ||
|
|
56
|
+
null
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function eventTime(event) {
|
|
61
|
+
const value = Date.parse(event.ts || "");
|
|
62
|
+
return Number.isFinite(value) ? value : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function eventHarness(event, fallback = null) {
|
|
66
|
+
return normalizeHarness(event?.harness || event?.integration || fallback);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function eventSources() {
|
|
70
|
+
const sources = [];
|
|
71
|
+
if (scopeHarness) {
|
|
72
|
+
sources.push({ file: harnessLogPath(scopeHarness, "events.jsonl"), fallbackHarness: scopeHarness });
|
|
73
|
+
sources.push({ file: logPath("events.jsonl"), fallbackHarness: null });
|
|
74
|
+
return sources;
|
|
75
|
+
}
|
|
76
|
+
for (const harness of knownHarnesses()) {
|
|
77
|
+
sources.push({ file: harnessLogPath(harness, "events.jsonl"), fallbackHarness: harness });
|
|
78
|
+
}
|
|
79
|
+
sources.push({ file: logPath("events.jsonl"), fallbackHarness: null });
|
|
80
|
+
return sources;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readEvents() {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const output = [];
|
|
86
|
+
for (const source of eventSources()) {
|
|
87
|
+
for (const event of readJsonl(source.file)) {
|
|
88
|
+
const harness = eventHarness(event, source.fallbackHarness);
|
|
89
|
+
if (scopeHarness && harness !== scopeHarness) continue;
|
|
90
|
+
const decisionId = decisionIdFor(event) || "";
|
|
91
|
+
const key = `${event.ts || ""}|${event.kind || ""}|${decisionId}|${event.sessionId || ""}|${event.path || ""}`;
|
|
92
|
+
if (seen.has(key)) continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
output.push({ ...event, harness: event.harness || harness || undefined });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return output.sort((a, b) => eventTime(a) - eventTime(b)).slice(-maxEvents);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function compactText(value, length = 260) {
|
|
101
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
102
|
+
return text.length > length ? `${text.slice(0, length - 1)}...` : text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function eventSummary(event) {
|
|
106
|
+
const route = event.route || {};
|
|
107
|
+
return {
|
|
108
|
+
ts: event.ts,
|
|
109
|
+
kind: event.kind,
|
|
110
|
+
harness: event.harness,
|
|
111
|
+
sessionId: event.sessionId || null,
|
|
112
|
+
requestedModel: event.requestedModel || null,
|
|
113
|
+
forwardedModel: event.forwardedModel || route.targetModel || null,
|
|
114
|
+
router: route.source || event.provider || null,
|
|
115
|
+
targetTier: route.targetTier || route.wouldTargetTier || null,
|
|
116
|
+
reasonCode: route.reasonCode || null,
|
|
117
|
+
confidence: route.confidence ?? null,
|
|
118
|
+
upstreamStatus: event.status ?? null,
|
|
119
|
+
upstreamLatencyMs: event.latencyMs ?? event.headerLatencyMs ?? null,
|
|
120
|
+
outputTextChars: event.outputTextChars ?? null,
|
|
121
|
+
promptPreview: event.promptPreview || event.payload?.context?.routingText || "",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildCalls() {
|
|
126
|
+
const rows = readEvents();
|
|
127
|
+
const grouped = new Map();
|
|
128
|
+
const ungrouped = [];
|
|
129
|
+
|
|
130
|
+
for (const event of rows) {
|
|
131
|
+
const id = decisionIdFor(event);
|
|
132
|
+
if (!id) {
|
|
133
|
+
ungrouped.push(event);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!grouped.has(id)) {
|
|
137
|
+
grouped.set(id, {
|
|
138
|
+
decisionId: id,
|
|
139
|
+
firstTs: event.ts,
|
|
140
|
+
lastTs: event.ts,
|
|
141
|
+
events: [],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const group = grouped.get(id);
|
|
145
|
+
group.events.push(event);
|
|
146
|
+
if (eventTime(event) < eventTime({ ts: group.firstTs })) group.firstTs = event.ts;
|
|
147
|
+
if (eventTime(event) > eventTime({ ts: group.lastTs })) group.lastTs = event.ts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const calls = [...grouped.values()].map((call) => {
|
|
151
|
+
const payload = call.events.find((event) => event.kind === "classification_api_payload" || event.kind === "routing_api_payload");
|
|
152
|
+
const decision = call.events.find((event) => event.kind === "classification_api_result" || event.kind === "routing_api_decision");
|
|
153
|
+
const modelRequest = call.events.find((event) => event.kind === "model_request");
|
|
154
|
+
const routeApplied = call.events.find((event) => event.kind === "route_applied");
|
|
155
|
+
const forwardAttempt = call.events.find((event) => event.kind === "forward_attempt");
|
|
156
|
+
const forwardResult = call.events.find((event) => event.kind === "forward_result");
|
|
157
|
+
const upstreamResponse = call.events.find((event) => event.kind === "upstream_response");
|
|
158
|
+
const route = routeApplied?.route || modelRequest?.route || decision?.route || {};
|
|
159
|
+
const packet = payload?.payload || null;
|
|
160
|
+
const requested = routeApplied?.requestedModel || modelRequest?.requestedModel || packet?.observed?.requestedModel || null;
|
|
161
|
+
const forwarded = routeApplied?.forwardedModel || forwardAttempt?.forwardedModel || route.targetModel || requested;
|
|
162
|
+
const payloadBytes = payload?.payloadBytes || routeApplied?.routingPacketSummary?.payloadBytes || modelRequest?.routingPacketSummary?.payloadBytes || 0;
|
|
163
|
+
const fullPacketBytes =
|
|
164
|
+
payload?.fullPacketBytes ||
|
|
165
|
+
routeApplied?.routingPacketSummary?.fullPacketBytes ||
|
|
166
|
+
modelRequest?.routingPacketSummary?.fullPacketBytes ||
|
|
167
|
+
0;
|
|
168
|
+
const prompt =
|
|
169
|
+
routeApplied?.promptPreview ||
|
|
170
|
+
modelRequest?.promptPreview ||
|
|
171
|
+
packet?.context?.routingText ||
|
|
172
|
+
packet?.context?.latestUserText ||
|
|
173
|
+
"";
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
decisionId: call.decisionId,
|
|
177
|
+
firstTs: call.firstTs,
|
|
178
|
+
lastTs: call.lastTs,
|
|
179
|
+
harness: routeApplied?.harness || modelRequest?.harness || payload?.harness || decision?.harness || "-",
|
|
180
|
+
router: route.source || decision?.provider || payload?.provider || "switchboard",
|
|
181
|
+
targetTier: route.targetTier || route.wouldTargetTier || "-",
|
|
182
|
+
difficulty: route.difficulty ?? null,
|
|
183
|
+
decision: route.decision || "-",
|
|
184
|
+
reasonCode: route.reasonCode || "-",
|
|
185
|
+
confidence: route.confidence ?? null,
|
|
186
|
+
requestedModel: requested || "-",
|
|
187
|
+
forwardedModel: forwarded || "-",
|
|
188
|
+
estimatedInputTokens: route.estimatedInputTokens || packet?.derived?.estimatedInputTokens || 0,
|
|
189
|
+
payloadBytes,
|
|
190
|
+
fullPacketBytes,
|
|
191
|
+
routingLatencyMs: route.routingApi?.latencyMs ?? null,
|
|
192
|
+
upstreamStatus: forwardResult?.status ?? null,
|
|
193
|
+
upstreamLatencyMs: upstreamResponse?.latencyMs ?? null,
|
|
194
|
+
upstreamBodyBytes: upstreamResponse?.bodyBytes ?? null,
|
|
195
|
+
upstreamOutputTextChars: upstreamResponse?.outputTextChars ?? null,
|
|
196
|
+
upstreamOk: upstreamResponse?.ok ?? null,
|
|
197
|
+
promptPreview: compactText(prompt, 320),
|
|
198
|
+
hasPayload: Boolean(payload),
|
|
199
|
+
hasDecision: Boolean(decision),
|
|
200
|
+
hasRouteApplied: Boolean(routeApplied),
|
|
201
|
+
hasForwardResult: Boolean(forwardResult),
|
|
202
|
+
hasUpstreamResponse: Boolean(upstreamResponse),
|
|
203
|
+
payload,
|
|
204
|
+
decisionEvent: decision,
|
|
205
|
+
modelRequest,
|
|
206
|
+
routeApplied,
|
|
207
|
+
forwardAttempt,
|
|
208
|
+
forwardResult,
|
|
209
|
+
upstreamResponse,
|
|
210
|
+
events: call.events.map(eventSummary),
|
|
211
|
+
rawEvents: call.events,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
calls.sort((a, b) => eventTime({ ts: b.lastTs }) - eventTime({ ts: a.lastTs }));
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
generatedAt: new Date().toISOString(),
|
|
219
|
+
logFile: scopeHarness ? harnessLogPath(scopeHarness, "events.jsonl") : logPath("events.jsonl"),
|
|
220
|
+
logFiles: eventSources().map((source) => source.file),
|
|
221
|
+
scopeHarness,
|
|
222
|
+
switchboardHome: switchboardHome(),
|
|
223
|
+
calls,
|
|
224
|
+
ungroupedCount: ungrouped.length,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleApi(req, res, url) {
|
|
229
|
+
if (url.pathname === "/api/calls") {
|
|
230
|
+
sendJson(res, 200, buildCalls());
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (url.pathname === "/api/status") {
|
|
234
|
+
const files = eventSources().map((source) => source.file);
|
|
235
|
+
const stats = files.map((file) => {
|
|
236
|
+
try {
|
|
237
|
+
const value = fs.statSync(file);
|
|
238
|
+
return { file, size: value.size, mtime: value.mtime.toISOString() };
|
|
239
|
+
} catch {
|
|
240
|
+
return { file, size: 0, mtime: null };
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
sendJson(res, 200, {
|
|
244
|
+
ok: true,
|
|
245
|
+
switchboardHome: switchboardHome(),
|
|
246
|
+
scopeHarness,
|
|
247
|
+
logFile: scopeHarness ? harnessLogPath(scopeHarness, "events.jsonl") : logPath("events.jsonl"),
|
|
248
|
+
logFiles: files,
|
|
249
|
+
stat: stats[0] || null,
|
|
250
|
+
stats,
|
|
251
|
+
});
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const html = String.raw`<!doctype html>
|
|
258
|
+
<html lang="en">
|
|
259
|
+
<head>
|
|
260
|
+
<meta charset="utf-8" />
|
|
261
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
262
|
+
<title>Switchboard Inspector</title>
|
|
263
|
+
<style>
|
|
264
|
+
:root {
|
|
265
|
+
color-scheme: dark;
|
|
266
|
+
--bg: #0d1014;
|
|
267
|
+
--panel: #151a20;
|
|
268
|
+
--panel-2: #1b222b;
|
|
269
|
+
--line: #303945;
|
|
270
|
+
--text: #eef3f7;
|
|
271
|
+
--muted: #9ba9b7;
|
|
272
|
+
--green: #3bc982;
|
|
273
|
+
--yellow: #f4bf52;
|
|
274
|
+
--blue: #63a8ff;
|
|
275
|
+
--red: #ff6b6b;
|
|
276
|
+
--teal: #41c7b9;
|
|
277
|
+
--code: #10141a;
|
|
278
|
+
}
|
|
279
|
+
* { box-sizing: border-box; }
|
|
280
|
+
body {
|
|
281
|
+
margin: 0;
|
|
282
|
+
min-height: 100vh;
|
|
283
|
+
background: var(--bg);
|
|
284
|
+
color: var(--text);
|
|
285
|
+
font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
286
|
+
}
|
|
287
|
+
header {
|
|
288
|
+
height: 58px;
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
justify-content: space-between;
|
|
292
|
+
padding: 0 18px;
|
|
293
|
+
border-bottom: 1px solid var(--line);
|
|
294
|
+
background: #11161c;
|
|
295
|
+
position: sticky;
|
|
296
|
+
top: 0;
|
|
297
|
+
z-index: 5;
|
|
298
|
+
}
|
|
299
|
+
h1 {
|
|
300
|
+
font-size: 17px;
|
|
301
|
+
font-weight: 650;
|
|
302
|
+
margin: 0;
|
|
303
|
+
letter-spacing: 0;
|
|
304
|
+
}
|
|
305
|
+
.header-meta {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 12px;
|
|
309
|
+
color: var(--muted);
|
|
310
|
+
font-size: 12px;
|
|
311
|
+
}
|
|
312
|
+
.layout {
|
|
313
|
+
display: grid;
|
|
314
|
+
grid-template-columns: minmax(360px, 42vw) minmax(520px, 1fr);
|
|
315
|
+
height: calc(100vh - 58px);
|
|
316
|
+
min-height: 620px;
|
|
317
|
+
}
|
|
318
|
+
aside {
|
|
319
|
+
border-right: 1px solid var(--line);
|
|
320
|
+
background: var(--panel);
|
|
321
|
+
min-width: 0;
|
|
322
|
+
overflow: hidden;
|
|
323
|
+
display: flex;
|
|
324
|
+
flex-direction: column;
|
|
325
|
+
}
|
|
326
|
+
main {
|
|
327
|
+
min-width: 0;
|
|
328
|
+
overflow: auto;
|
|
329
|
+
background: var(--bg);
|
|
330
|
+
}
|
|
331
|
+
.toolbar {
|
|
332
|
+
display: grid;
|
|
333
|
+
grid-template-columns: 1fr 108px 108px;
|
|
334
|
+
gap: 8px;
|
|
335
|
+
padding: 12px;
|
|
336
|
+
border-bottom: 1px solid var(--line);
|
|
337
|
+
background: var(--panel);
|
|
338
|
+
}
|
|
339
|
+
input, select {
|
|
340
|
+
width: 100%;
|
|
341
|
+
height: 34px;
|
|
342
|
+
border: 1px solid var(--line);
|
|
343
|
+
border-radius: 6px;
|
|
344
|
+
background: #0f141a;
|
|
345
|
+
color: var(--text);
|
|
346
|
+
padding: 0 10px;
|
|
347
|
+
font: inherit;
|
|
348
|
+
outline: none;
|
|
349
|
+
}
|
|
350
|
+
input:focus, select:focus { border-color: var(--blue); }
|
|
351
|
+
.call-list {
|
|
352
|
+
overflow: auto;
|
|
353
|
+
min-height: 0;
|
|
354
|
+
}
|
|
355
|
+
.call {
|
|
356
|
+
width: 100%;
|
|
357
|
+
text-align: left;
|
|
358
|
+
border: 0;
|
|
359
|
+
border-bottom: 1px solid var(--line);
|
|
360
|
+
background: transparent;
|
|
361
|
+
color: inherit;
|
|
362
|
+
padding: 12px;
|
|
363
|
+
cursor: pointer;
|
|
364
|
+
display: block;
|
|
365
|
+
}
|
|
366
|
+
.call:hover { background: #1a2028; }
|
|
367
|
+
.call.active {
|
|
368
|
+
background: #202833;
|
|
369
|
+
box-shadow: inset 3px 0 0 var(--teal);
|
|
370
|
+
}
|
|
371
|
+
.row {
|
|
372
|
+
display: flex;
|
|
373
|
+
align-items: center;
|
|
374
|
+
justify-content: space-between;
|
|
375
|
+
gap: 10px;
|
|
376
|
+
min-width: 0;
|
|
377
|
+
}
|
|
378
|
+
.model {
|
|
379
|
+
color: var(--text);
|
|
380
|
+
font-size: 13px;
|
|
381
|
+
white-space: nowrap;
|
|
382
|
+
overflow: hidden;
|
|
383
|
+
text-overflow: ellipsis;
|
|
384
|
+
min-width: 0;
|
|
385
|
+
}
|
|
386
|
+
.time {
|
|
387
|
+
color: var(--muted);
|
|
388
|
+
font-variant-numeric: tabular-nums;
|
|
389
|
+
font-size: 12px;
|
|
390
|
+
white-space: nowrap;
|
|
391
|
+
}
|
|
392
|
+
.prompt {
|
|
393
|
+
color: #c8d2dc;
|
|
394
|
+
margin-top: 8px;
|
|
395
|
+
font-size: 13px;
|
|
396
|
+
min-height: 18px;
|
|
397
|
+
}
|
|
398
|
+
.chips {
|
|
399
|
+
display: flex;
|
|
400
|
+
flex-wrap: wrap;
|
|
401
|
+
gap: 6px;
|
|
402
|
+
margin-top: 9px;
|
|
403
|
+
}
|
|
404
|
+
.chip {
|
|
405
|
+
display: inline-flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
min-height: 22px;
|
|
408
|
+
border: 1px solid var(--line);
|
|
409
|
+
border-radius: 999px;
|
|
410
|
+
padding: 2px 8px;
|
|
411
|
+
color: #dbe6ef;
|
|
412
|
+
background: #111820;
|
|
413
|
+
font-size: 12px;
|
|
414
|
+
white-space: nowrap;
|
|
415
|
+
}
|
|
416
|
+
.chip.lower { border-color: rgba(59, 201, 130, .55); color: var(--green); }
|
|
417
|
+
.chip.mid { border-color: rgba(244, 191, 82, .55); color: var(--yellow); }
|
|
418
|
+
.chip.best { border-color: rgba(99, 168, 255, .55); color: var(--blue); }
|
|
419
|
+
.chip.error { border-color: rgba(255, 107, 107, .65); color: var(--red); }
|
|
420
|
+
.detail {
|
|
421
|
+
padding: 16px;
|
|
422
|
+
max-width: 1280px;
|
|
423
|
+
margin: 0 auto;
|
|
424
|
+
}
|
|
425
|
+
.summary {
|
|
426
|
+
display: grid;
|
|
427
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
428
|
+
gap: 10px;
|
|
429
|
+
margin-bottom: 14px;
|
|
430
|
+
}
|
|
431
|
+
.metric {
|
|
432
|
+
border: 1px solid var(--line);
|
|
433
|
+
background: var(--panel);
|
|
434
|
+
border-radius: 8px;
|
|
435
|
+
padding: 10px;
|
|
436
|
+
min-width: 0;
|
|
437
|
+
}
|
|
438
|
+
.metric .label {
|
|
439
|
+
color: var(--muted);
|
|
440
|
+
font-size: 12px;
|
|
441
|
+
margin-bottom: 5px;
|
|
442
|
+
}
|
|
443
|
+
.metric .value {
|
|
444
|
+
font-size: 16px;
|
|
445
|
+
font-weight: 650;
|
|
446
|
+
white-space: nowrap;
|
|
447
|
+
overflow: hidden;
|
|
448
|
+
text-overflow: ellipsis;
|
|
449
|
+
}
|
|
450
|
+
.tabs {
|
|
451
|
+
display: flex;
|
|
452
|
+
gap: 6px;
|
|
453
|
+
border-bottom: 1px solid var(--line);
|
|
454
|
+
margin-top: 14px;
|
|
455
|
+
}
|
|
456
|
+
.tab {
|
|
457
|
+
border: 1px solid transparent;
|
|
458
|
+
border-bottom: 0;
|
|
459
|
+
background: transparent;
|
|
460
|
+
color: var(--muted);
|
|
461
|
+
padding: 9px 12px;
|
|
462
|
+
border-radius: 7px 7px 0 0;
|
|
463
|
+
cursor: pointer;
|
|
464
|
+
font: inherit;
|
|
465
|
+
}
|
|
466
|
+
.tab.active {
|
|
467
|
+
background: var(--panel);
|
|
468
|
+
color: var(--text);
|
|
469
|
+
border-color: var(--line);
|
|
470
|
+
}
|
|
471
|
+
.pane {
|
|
472
|
+
display: none;
|
|
473
|
+
border: 1px solid var(--line);
|
|
474
|
+
border-top: 0;
|
|
475
|
+
background: var(--panel);
|
|
476
|
+
border-radius: 0 0 8px 8px;
|
|
477
|
+
min-height: 360px;
|
|
478
|
+
}
|
|
479
|
+
.pane.active { display: block; }
|
|
480
|
+
.split {
|
|
481
|
+
display: grid;
|
|
482
|
+
grid-template-columns: 1fr 1fr;
|
|
483
|
+
gap: 12px;
|
|
484
|
+
padding: 12px;
|
|
485
|
+
}
|
|
486
|
+
.block {
|
|
487
|
+
min-width: 0;
|
|
488
|
+
border: 1px solid var(--line);
|
|
489
|
+
background: var(--panel-2);
|
|
490
|
+
border-radius: 8px;
|
|
491
|
+
overflow: hidden;
|
|
492
|
+
}
|
|
493
|
+
.block-title {
|
|
494
|
+
display: flex;
|
|
495
|
+
align-items: center;
|
|
496
|
+
justify-content: space-between;
|
|
497
|
+
gap: 10px;
|
|
498
|
+
padding: 9px 10px;
|
|
499
|
+
color: var(--muted);
|
|
500
|
+
border-bottom: 1px solid var(--line);
|
|
501
|
+
font-size: 12px;
|
|
502
|
+
text-transform: uppercase;
|
|
503
|
+
}
|
|
504
|
+
pre {
|
|
505
|
+
margin: 0;
|
|
506
|
+
padding: 12px;
|
|
507
|
+
white-space: pre-wrap;
|
|
508
|
+
word-break: break-word;
|
|
509
|
+
overflow: auto;
|
|
510
|
+
max-height: 62vh;
|
|
511
|
+
background: var(--code);
|
|
512
|
+
color: #dce8f2;
|
|
513
|
+
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
514
|
+
}
|
|
515
|
+
.empty {
|
|
516
|
+
padding: 36px;
|
|
517
|
+
color: var(--muted);
|
|
518
|
+
text-align: center;
|
|
519
|
+
}
|
|
520
|
+
.copy {
|
|
521
|
+
border: 1px solid var(--line);
|
|
522
|
+
background: #10161d;
|
|
523
|
+
color: var(--text);
|
|
524
|
+
border-radius: 5px;
|
|
525
|
+
height: 26px;
|
|
526
|
+
padding: 0 8px;
|
|
527
|
+
cursor: pointer;
|
|
528
|
+
font-size: 12px;
|
|
529
|
+
}
|
|
530
|
+
.copy:hover { border-color: var(--blue); }
|
|
531
|
+
@media (max-width: 980px) {
|
|
532
|
+
.layout { grid-template-columns: 1fr; height: auto; }
|
|
533
|
+
aside { height: 48vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
534
|
+
.summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
535
|
+
.split { grid-template-columns: 1fr; }
|
|
536
|
+
header { position: static; }
|
|
537
|
+
}
|
|
538
|
+
</style>
|
|
539
|
+
</head>
|
|
540
|
+
<body>
|
|
541
|
+
<header>
|
|
542
|
+
<h1>Switchboard Inspector</h1>
|
|
543
|
+
<div class="header-meta">
|
|
544
|
+
<span id="status">Loading...</span>
|
|
545
|
+
<span id="log-path"></span>
|
|
546
|
+
</div>
|
|
547
|
+
</header>
|
|
548
|
+
<div class="layout">
|
|
549
|
+
<aside>
|
|
550
|
+
<div class="toolbar">
|
|
551
|
+
<input id="search" placeholder="Search calls" />
|
|
552
|
+
<select id="tier">
|
|
553
|
+
<option value="">All tiers</option>
|
|
554
|
+
<option value="lower">Lower</option>
|
|
555
|
+
<option value="mid">Mid</option>
|
|
556
|
+
<option value="best">Best</option>
|
|
557
|
+
<option value="error">Errors</option>
|
|
558
|
+
</select>
|
|
559
|
+
<select id="tool">
|
|
560
|
+
<option value="">All tools</option>
|
|
561
|
+
<option value="codex">Codex</option>
|
|
562
|
+
<option value="claude">Claude</option>
|
|
563
|
+
</select>
|
|
564
|
+
</div>
|
|
565
|
+
<div id="call-list" class="call-list"></div>
|
|
566
|
+
</aside>
|
|
567
|
+
<main>
|
|
568
|
+
<div id="detail" class="detail">
|
|
569
|
+
<div class="empty">Waiting for route events.</div>
|
|
570
|
+
</div>
|
|
571
|
+
</main>
|
|
572
|
+
</div>
|
|
573
|
+
<script>
|
|
574
|
+
const state = { calls: [], selectedId: null, tab: 'overview', query: '', tier: '', tool: '', autoRefresh: true };
|
|
575
|
+
const $ = (id) => document.getElementById(id);
|
|
576
|
+
const formatJson = (value) => JSON.stringify(value ?? null, null, 2);
|
|
577
|
+
const safe = (value) => String(value ?? '');
|
|
578
|
+
const shortTime = (value) => value ? new Date(value).toLocaleTimeString() : '-';
|
|
579
|
+
const compactNumber = (value) => {
|
|
580
|
+
const n = Number(value || 0);
|
|
581
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'm';
|
|
582
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
|
583
|
+
return String(n);
|
|
584
|
+
};
|
|
585
|
+
const tierClass = (call) => call.reasonCode === 'routing_api_error' ? 'error' : safe(call.targetTier);
|
|
586
|
+
const isMatch = (call) => {
|
|
587
|
+
const q = state.query.toLowerCase().trim();
|
|
588
|
+
const text = [
|
|
589
|
+
call.decisionId, call.harness, call.router, call.targetTier, call.reasonCode,
|
|
590
|
+
call.requestedModel, call.forwardedModel, call.promptPreview
|
|
591
|
+
].join(' ').toLowerCase();
|
|
592
|
+
if (q && !text.includes(q)) return false;
|
|
593
|
+
if (state.tool && call.harness !== state.tool) return false;
|
|
594
|
+
if (state.tier === 'error' && call.reasonCode !== 'routing_api_error') return false;
|
|
595
|
+
if (state.tier && state.tier !== 'error' && call.targetTier !== state.tier) return false;
|
|
596
|
+
return true;
|
|
597
|
+
};
|
|
598
|
+
function copyJson(id) {
|
|
599
|
+
const node = document.querySelector('[data-json-id="' + id + '"]');
|
|
600
|
+
if (!node) return;
|
|
601
|
+
navigator.clipboard?.writeText(node.textContent);
|
|
602
|
+
}
|
|
603
|
+
window.copyJson = copyJson;
|
|
604
|
+
function jsonBlock(title, id, value) {
|
|
605
|
+
return '<div class="block"><div class="block-title"><span>' + escapeHtml(title) + '</span><button class="copy" data-copy-id="' + escapeHtml(id) + '">Copy</button></div><pre data-json-id="' + escapeHtml(id) + '">' + escapeHtml(formatJson(value)) + '</pre></div>';
|
|
606
|
+
}
|
|
607
|
+
function escapeHtml(value) {
|
|
608
|
+
return String(value).replace(/[&<>"']/g, (ch) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
|
609
|
+
}
|
|
610
|
+
function renderList() {
|
|
611
|
+
const calls = state.calls.filter(isMatch);
|
|
612
|
+
$('call-list').innerHTML = calls.length ? calls.map((call) => {
|
|
613
|
+
const active = call.decisionId === state.selectedId ? ' active' : '';
|
|
614
|
+
const chipClass = tierClass(call);
|
|
615
|
+
return '<button class="call' + active + '" data-id="' + call.decisionId + '">' +
|
|
616
|
+
'<div class="row"><div class="model">' + escapeHtml(call.requestedModel + ' -> ' + call.forwardedModel) + '</div><div class="time">' + shortTime(call.lastTs) + '</div></div>' +
|
|
617
|
+
'<div class="prompt">' + escapeHtml(call.promptPreview || '(no preview)') + '</div>' +
|
|
618
|
+
'<div class="chips">' +
|
|
619
|
+
'<span class="chip">' + escapeHtml(call.harness) + '</span>' +
|
|
620
|
+
'<span class="chip">' + escapeHtml(call.router) + '</span>' +
|
|
621
|
+
'<span class="chip ' + chipClass + '">' + escapeHtml(call.targetTier || '-') + '</span>' +
|
|
622
|
+
'<span class="chip">' + escapeHtml(call.reasonCode || '-') + '</span>' +
|
|
623
|
+
'<span class="chip">' + compactNumber(call.estimatedInputTokens) + ' tok</span>' +
|
|
624
|
+
'</div>' +
|
|
625
|
+
'</button>';
|
|
626
|
+
}).join('') : '<div class="empty">No matching calls.</div>';
|
|
627
|
+
document.querySelectorAll('.call').forEach((node) => {
|
|
628
|
+
node.addEventListener('click', () => {
|
|
629
|
+
state.selectedId = node.dataset.id;
|
|
630
|
+
render();
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function scrollTargets() {
|
|
635
|
+
return {
|
|
636
|
+
list: $('call-list'),
|
|
637
|
+
main: document.querySelector('main'),
|
|
638
|
+
panes: [...document.querySelectorAll('.pane.active pre')],
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function snapshotScroll() {
|
|
642
|
+
const targets = scrollTargets();
|
|
643
|
+
return {
|
|
644
|
+
listTop: targets.list?.scrollTop || 0,
|
|
645
|
+
mainTop: targets.main?.scrollTop || 0,
|
|
646
|
+
paneTops: targets.panes.map((node) => node.scrollTop || 0),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function restoreScroll(snapshot) {
|
|
650
|
+
if (!snapshot) return;
|
|
651
|
+
requestAnimationFrame(() => {
|
|
652
|
+
const targets = scrollTargets();
|
|
653
|
+
if (targets.list) targets.list.scrollTop = snapshot.listTop;
|
|
654
|
+
if (targets.main) targets.main.scrollTop = snapshot.mainTop;
|
|
655
|
+
targets.panes.forEach((node, index) => {
|
|
656
|
+
node.scrollTop = snapshot.paneTops[index] || 0;
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function metric(label, value) {
|
|
661
|
+
return '<div class="metric"><div class="label">' + escapeHtml(label) + '</div><div class="value">' + escapeHtml(value) + '</div></div>';
|
|
662
|
+
}
|
|
663
|
+
function tabs(call) {
|
|
664
|
+
const names = [
|
|
665
|
+
['overview', 'Overview'],
|
|
666
|
+
['payload', 'Payload'],
|
|
667
|
+
['decision', 'Decision'],
|
|
668
|
+
['applied', 'Applied Route'],
|
|
669
|
+
['result', 'Upstream Result'],
|
|
670
|
+
['raw', 'Raw Events']
|
|
671
|
+
];
|
|
672
|
+
return '<div class="tabs">' + names.map(([id, label]) =>
|
|
673
|
+
'<button class="tab ' + (state.tab === id ? 'active' : '') + '" data-tab="' + id + '">' + label + '</button>'
|
|
674
|
+
).join('') + '</div>' +
|
|
675
|
+
names.map(([id]) => '<div class="pane ' + (state.tab === id ? 'active' : '') + '">' + paneContent(id, call) + '</div>').join('');
|
|
676
|
+
}
|
|
677
|
+
function paneContent(id, call) {
|
|
678
|
+
if (id === 'overview') {
|
|
679
|
+
return '<div class="split">' +
|
|
680
|
+
jsonBlock('Context', 'context', call.payload?.payload?.context) +
|
|
681
|
+
jsonBlock('Route', 'route', call.routeApplied?.route || call.modelRequest?.route || call.decisionEvent?.route) +
|
|
682
|
+
'</div>';
|
|
683
|
+
}
|
|
684
|
+
if (id === 'payload') {
|
|
685
|
+
return '<div class="split">' +
|
|
686
|
+
jsonBlock('Compact Classifier Payload', 'payload', call.payload?.payload) +
|
|
687
|
+
jsonBlock('Full Packet Summary', 'observed', call.payload?.fullPacketSummary || { observed: call.payload?.payload?.observed, derived: call.payload?.payload?.derived }) +
|
|
688
|
+
'</div>';
|
|
689
|
+
}
|
|
690
|
+
if (id === 'decision') {
|
|
691
|
+
return '<div class="split">' +
|
|
692
|
+
jsonBlock('Routing API Decision Event', 'decision', call.decisionEvent) +
|
|
693
|
+
jsonBlock('Classifier Response', 'classifier', call.decisionEvent?.route?.routingApi) +
|
|
694
|
+
'</div>';
|
|
695
|
+
}
|
|
696
|
+
if (id === 'applied') {
|
|
697
|
+
return '<div class="split">' +
|
|
698
|
+
jsonBlock('Route Applied', 'applied', call.routeApplied) +
|
|
699
|
+
jsonBlock('Forwarding', 'forwarding', { attempt: call.forwardAttempt, result: call.forwardResult }) +
|
|
700
|
+
'</div>';
|
|
701
|
+
}
|
|
702
|
+
if (id === 'result') {
|
|
703
|
+
return '<div class="split">' +
|
|
704
|
+
jsonBlock('Upstream Response Summary', 'upstream-response', call.upstreamResponse) +
|
|
705
|
+
jsonBlock('Result Audit', 'result-audit', {
|
|
706
|
+
decisionId: call.decisionId,
|
|
707
|
+
originalRequestedModel: call.upstreamResponse?.originalRequestedModel || call.requestedModel,
|
|
708
|
+
requestedModel: call.requestedModel,
|
|
709
|
+
forwardedModel: call.forwardedModel,
|
|
710
|
+
targetTier: call.targetTier,
|
|
711
|
+
reasonCode: call.reasonCode,
|
|
712
|
+
classifierConfidence: call.confidence,
|
|
713
|
+
upstreamStatus: call.upstreamStatus,
|
|
714
|
+
upstreamOk: call.upstreamOk,
|
|
715
|
+
upstreamLatencyMs: call.upstreamLatencyMs,
|
|
716
|
+
upstreamOutputTextChars: call.upstreamOutputTextChars,
|
|
717
|
+
outputPreview: call.upstreamResponse?.outputPreview || '',
|
|
718
|
+
usage: call.upstreamResponse?.usage || null,
|
|
719
|
+
errorSummary: call.upstreamResponse?.errorSummary || null,
|
|
720
|
+
}) +
|
|
721
|
+
'</div>';
|
|
722
|
+
}
|
|
723
|
+
return '<div class="split">' + jsonBlock('Raw Events', 'raw', call.rawEvents) + jsonBlock('Event Summaries', 'summaries', call.events) + '</div>';
|
|
724
|
+
}
|
|
725
|
+
function renderDetail() {
|
|
726
|
+
const call = state.calls.find((item) => item.decisionId === state.selectedId) || state.calls[0];
|
|
727
|
+
if (!call) {
|
|
728
|
+
$('detail').innerHTML = '<div class="empty">No route events yet.</div>';
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
state.selectedId = call.decisionId;
|
|
732
|
+
$('detail').innerHTML =
|
|
733
|
+
'<div class="summary">' +
|
|
734
|
+
metric('Tier', call.targetTier || '-') +
|
|
735
|
+
metric('Route', call.requestedModel + ' -> ' + call.forwardedModel) +
|
|
736
|
+
metric('Reason', call.reasonCode || '-') +
|
|
737
|
+
metric('Classifier latency', call.routingLatencyMs == null ? '-' : call.routingLatencyMs + 'ms') +
|
|
738
|
+
metric('Input tokens', compactNumber(call.estimatedInputTokens)) +
|
|
739
|
+
metric('Classifier payload', compactNumber(call.payloadBytes) + ' bytes') +
|
|
740
|
+
metric('Full packet', compactNumber(call.fullPacketBytes) + ' bytes') +
|
|
741
|
+
metric('Upstream', call.upstreamStatus == null ? '-' : String(call.upstreamStatus)) +
|
|
742
|
+
metric('Result latency', call.upstreamLatencyMs == null ? '-' : call.upstreamLatencyMs + 'ms') +
|
|
743
|
+
metric('Output chars', call.upstreamOutputTextChars == null ? '-' : compactNumber(call.upstreamOutputTextChars)) +
|
|
744
|
+
'</div>' +
|
|
745
|
+
'<div class="block"><div class="block-title"><span>Prompt Preview</span></div><pre>' + escapeHtml(call.promptPreview || '') + '</pre></div>' +
|
|
746
|
+
tabs(call);
|
|
747
|
+
document.querySelectorAll('.tab').forEach((node) => {
|
|
748
|
+
node.addEventListener('click', () => {
|
|
749
|
+
state.tab = node.dataset.tab;
|
|
750
|
+
renderDetail();
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
document.querySelectorAll('[data-copy-id]').forEach((node) => {
|
|
754
|
+
node.addEventListener('click', () => copyJson(node.dataset.copyId));
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function render(options = {}) {
|
|
758
|
+
const scroll = options.preserveScroll ? snapshotScroll() : null;
|
|
759
|
+
renderList();
|
|
760
|
+
renderDetail();
|
|
761
|
+
restoreScroll(scroll);
|
|
762
|
+
}
|
|
763
|
+
async function load() {
|
|
764
|
+
if (!state.autoRefresh && state.calls.length) return;
|
|
765
|
+
const scroll = snapshotScroll();
|
|
766
|
+
const res = await fetch('/api/calls');
|
|
767
|
+
const data = await res.json();
|
|
768
|
+
state.calls = data.calls || [];
|
|
769
|
+
$('status').textContent = state.calls.length + ' grouped calls | updated ' + new Date(data.generatedAt).toLocaleTimeString();
|
|
770
|
+
$('log-path').textContent = data.logFile || '';
|
|
771
|
+
if (!state.selectedId && state.calls[0]) state.selectedId = state.calls[0].decisionId;
|
|
772
|
+
render({ preserveScroll: true });
|
|
773
|
+
restoreScroll(scroll);
|
|
774
|
+
}
|
|
775
|
+
$('search').addEventListener('input', (event) => { state.query = event.target.value; renderList(); });
|
|
776
|
+
$('tier').addEventListener('change', (event) => { state.tier = event.target.value; renderList(); });
|
|
777
|
+
$('tool').addEventListener('change', (event) => { state.tool = event.target.value; renderList(); });
|
|
778
|
+
load();
|
|
779
|
+
setInterval(load, 2500);
|
|
780
|
+
</script>
|
|
781
|
+
</body>
|
|
782
|
+
</html>`;
|
|
783
|
+
|
|
784
|
+
function createServer() {
|
|
785
|
+
return http.createServer((req, res) => {
|
|
786
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
787
|
+
if (handleApi(req, res, url)) return;
|
|
788
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
789
|
+
send(res, 200, html, { "content-type": "text/html; charset=utf-8" });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
sendJson(res, 404, { error: { message: "Not found" } });
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function startServer(selectedPort, attempt = 0) {
|
|
797
|
+
const server = createServer();
|
|
798
|
+
server.once("error", (error) => {
|
|
799
|
+
if (error?.code === "EADDRINUSE" && attempt < 50) {
|
|
800
|
+
const nextPort = selectedPort + 1;
|
|
801
|
+
console.error(`Switchboard inspector port ${selectedPort} is busy; using ${nextPort}.`);
|
|
802
|
+
startServer(nextPort, attempt + 1);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
throw error;
|
|
806
|
+
});
|
|
807
|
+
server.listen(selectedPort, host, () => {
|
|
808
|
+
console.log(`Switchboard inspector listening on http://${host}:${selectedPort}`);
|
|
809
|
+
console.log(`Scope: ${scopeHarness || "all harnesses"}`);
|
|
810
|
+
console.log(`Log files: ${eventSources().map((source) => source.file).join(", ")}`);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
startServer(port);
|