llm-switcher 1.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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
package/contract.mjs
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// Contract lab, llm-switcher half. It tags a sampled request with a trace id and uploads this
|
|
2
|
+
// side of the exchange to intact, which joins both halves and finds what a converter loses.
|
|
3
|
+
// Nothing here may delay, fail or change the answer a coding tool receives: every entry point
|
|
4
|
+
// returns at once, and every failure is logged and dropped.
|
|
5
|
+
// Wire shape of the policy: `{models: {"<model>": rate}, default: rate}`, rate 0 to 1. The half
|
|
6
|
+
// body is built in `send`.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { getActiveMap, modelSlotsForProfile, modelForSlot } from './state.mjs';
|
|
13
|
+
|
|
14
|
+
const ROOT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
export const TRACE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
17
|
+
export const SWITCHER_VERSION_RE = /^\d{1,5}\.\d{1,5}\.\d{1,5}\+\d{8}T\d{6}Z$/;
|
|
18
|
+
export const TOOL_VERSION_RE = /^\d{1,5}\.\d{1,5}\.\d{1,5}([-+][0-9A-Za-z.]{1,32})?$/;
|
|
19
|
+
export const POLICY_REFRESH_MS = 10 * 60 * 1000;
|
|
20
|
+
export const MAX_QUEUE = 32;
|
|
21
|
+
export const MAX_HALF_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
/** A trace id the client cannot choose: 32 url-safe characters from the system random source. */
|
|
25
|
+
export function newTraceId() {
|
|
26
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The coding tool version is the text after the first slash of the User-Agent, up to the first
|
|
30
|
+
// space. intact answers 400 to any other value, so an unreadable one is left out of the half.
|
|
31
|
+
export function toolVersionFromUA(ua) {
|
|
32
|
+
const first = String(ua || '').split(' ')[0];
|
|
33
|
+
const slash = first.indexOf('/');
|
|
34
|
+
if (slash < 0) return '';
|
|
35
|
+
const version = first.slice(slash + 1);
|
|
36
|
+
return TOOL_VERSION_RE.test(version) ? version : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const utcStamp = (ms) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
40
|
+
|
|
41
|
+
let cachedVersion = '';
|
|
42
|
+
|
|
43
|
+
// package version + the commit time, the shape the half route demands. Without git (an unpacked
|
|
44
|
+
// copy) the mtime of package.json stands in, so the value always parses as a past UTC time.
|
|
45
|
+
export function switcherVersion() {
|
|
46
|
+
if (cachedVersion) return cachedVersion;
|
|
47
|
+
let version = '0.0.0';
|
|
48
|
+
let stampMs = Date.now();
|
|
49
|
+
try {
|
|
50
|
+
const pkgPath = path.join(ROOT_DIR, 'package.json');
|
|
51
|
+
const parsed = /^(\d{1,5}\.\d{1,5}\.\d{1,5})/.exec(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '');
|
|
52
|
+
if (parsed) version = parsed[1];
|
|
53
|
+
stampMs = fs.statSync(pkgPath).mtimeMs;
|
|
54
|
+
} catch {}
|
|
55
|
+
try {
|
|
56
|
+
const out = execFileSync('git', ['-C', ROOT_DIR, 'log', '-1', '--format=%ct'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
57
|
+
if (/^\d{1,12}$/.test(out)) stampMs = Number(out) * 1000;
|
|
58
|
+
} catch {}
|
|
59
|
+
cachedVersion = `${version}+${utcStamp(stampMs)}`;
|
|
60
|
+
return cachedVersion;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Copies what the gateway writes to the client. Past the cap the side is dropped, not cut. */
|
|
64
|
+
export function createHalfTap(limit = MAX_HALF_BYTES) {
|
|
65
|
+
const chunks = [];
|
|
66
|
+
let size = 0;
|
|
67
|
+
let over = false;
|
|
68
|
+
return {
|
|
69
|
+
push(chunk) {
|
|
70
|
+
try {
|
|
71
|
+
if (over || chunk === undefined || chunk === null) return;
|
|
72
|
+
const buf = Buffer.isBuffer(chunk) ? chunk
|
|
73
|
+
: ArrayBuffer.isView(chunk) ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
74
|
+
: Buffer.from(String(chunk), 'utf8');
|
|
75
|
+
size += buf.length;
|
|
76
|
+
if (size > limit) {
|
|
77
|
+
over = true;
|
|
78
|
+
chunks.length = 0;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(buf);
|
|
82
|
+
} catch {}
|
|
83
|
+
},
|
|
84
|
+
// A side over the cap would get 413 from intact, so it is sent empty and only that
|
|
85
|
+
// direction is skipped.
|
|
86
|
+
text() {
|
|
87
|
+
return over ? '' : Buffer.concat(chunks).toString('utf8');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The client request half, or '' when it is over the cap intact accepts. */
|
|
93
|
+
export function capText(buf, limit = MAX_HALF_BYTES) {
|
|
94
|
+
if (!buf) return '';
|
|
95
|
+
return buf.length > limit ? '' : buf.toString('utf8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The WS transport hands the handler a parsed message, not the bytes of the frame. The shape
|
|
99
|
+
// survives the round trip, which is all intact reduces. A value JSON cannot hold gives ''.
|
|
100
|
+
export function capJson(value, limit = MAX_HALF_BYTES) {
|
|
101
|
+
try {
|
|
102
|
+
return capText(Buffer.from(JSON.stringify(value) ?? '', 'utf8'), limit);
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Copies every byte the handler writes to the client. It never changes them, and a tap failure
|
|
109
|
+
// is swallowed inside the tap.
|
|
110
|
+
export function tapClientWrites(res, tap) {
|
|
111
|
+
const write = res.write.bind(res);
|
|
112
|
+
const end = res.end.bind(res);
|
|
113
|
+
res.write = function tappedWrite(chunk, ...rest) {
|
|
114
|
+
if (typeof chunk !== 'function') tap.push(chunk);
|
|
115
|
+
return write(chunk, ...rest);
|
|
116
|
+
};
|
|
117
|
+
res.end = function tappedEnd(chunk, ...rest) {
|
|
118
|
+
if (typeof chunk !== 'function') tap.push(chunk);
|
|
119
|
+
return end(chunk, ...rest);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The only place the upload guard lives. An untagged request was never sampled, and an aborted
|
|
124
|
+
// or failed turn has no complete answer: uploading it would diff as a loss the converter never made.
|
|
125
|
+
export function finishHalf(lab, traceId, failed, half) {
|
|
126
|
+
if (!traceId || failed) return;
|
|
127
|
+
lab.upload(traceId, half);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const clamp = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
|
|
131
|
+
|
|
132
|
+
function readPolicy(body) {
|
|
133
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
134
|
+
const models = {};
|
|
135
|
+
if (body.models && typeof body.models === 'object' && !Array.isArray(body.models)) {
|
|
136
|
+
for (const [model, rate] of Object.entries(body.models)) {
|
|
137
|
+
if (typeof rate === 'number' && Number.isFinite(rate)) models[model] = clamp(rate);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const fallback = typeof body.default === 'number' && Number.isFinite(body.default) ? clamp(body.default) : 0;
|
|
141
|
+
return { models, default: fallback };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The url and key of a lab that is on and fully configured, or null. */
|
|
145
|
+
function activeSettings(s) {
|
|
146
|
+
if (!s || s.enabled !== true || !s.url || !s.apiKey) return null;
|
|
147
|
+
return { url: String(s.url).replace(/\/+$/, ''), apiKey: String(s.apiKey) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createContractLab(options = {}) {
|
|
151
|
+
const {
|
|
152
|
+
settings = () => null,
|
|
153
|
+
fetchImpl = (...args) => fetch(...args),
|
|
154
|
+
now = () => Date.now(),
|
|
155
|
+
random = Math.random,
|
|
156
|
+
version = switcherVersion,
|
|
157
|
+
log = (message) => console.error(`[llm-switcher:contract] ${message}`)
|
|
158
|
+
} = options;
|
|
159
|
+
|
|
160
|
+
let policy = null;
|
|
161
|
+
// Never refreshed yet. A failed refresh sets a real time, so a down intact is asked again
|
|
162
|
+
// only after the interval, not on every request.
|
|
163
|
+
let policyAt = Number.NEGATIVE_INFINITY;
|
|
164
|
+
let refreshing = false;
|
|
165
|
+
let pumping = false;
|
|
166
|
+
const queue = [];
|
|
167
|
+
const stats = { sampled: 0, dropped: 0, done: 0, failed: 0 };
|
|
168
|
+
|
|
169
|
+
const active = () => activeSettings(settings());
|
|
170
|
+
|
|
171
|
+
const call = (url, opts) => fetchImpl(url, { ...opts, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
172
|
+
|
|
173
|
+
async function refreshPolicy(s) {
|
|
174
|
+
refreshing = true;
|
|
175
|
+
try {
|
|
176
|
+
const res = await call(`${s.url}/api/contracts/policy`, { headers: { authorization: `Bearer ${s.apiKey}` } });
|
|
177
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
178
|
+
const next = readPolicy(await res.json());
|
|
179
|
+
if (!next) throw new Error('unreadable policy');
|
|
180
|
+
policy = next;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Keep the policy that worked. With none, nothing is sampled until a refresh succeeds.
|
|
183
|
+
log(`policy refresh failed: ${err.message}`);
|
|
184
|
+
} finally {
|
|
185
|
+
policyAt = now();
|
|
186
|
+
refreshing = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The trace id for this request, or null. Never throws, never waits. */
|
|
191
|
+
function traceFor(model) {
|
|
192
|
+
try {
|
|
193
|
+
const s = active();
|
|
194
|
+
if (!s) return null;
|
|
195
|
+
if (!refreshing && now() - policyAt >= POLICY_REFRESH_MS) void refreshPolicy(s);
|
|
196
|
+
if (!policy) return null;
|
|
197
|
+
const byModel = policy.models[model];
|
|
198
|
+
const rate = typeof byModel === 'number' ? byModel : policy.default;
|
|
199
|
+
if (!(rate > 0) || random() >= rate) return null;
|
|
200
|
+
stats.sampled++;
|
|
201
|
+
return newTraceId();
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function send(s, { traceId, half }) {
|
|
208
|
+
const body = JSON.stringify({
|
|
209
|
+
toolRequest: half.toolRequest || '',
|
|
210
|
+
toolResponse: half.toolResponse || '',
|
|
211
|
+
...(half.toolVersion ? { toolVersion: half.toolVersion } : {}),
|
|
212
|
+
switcherVersion: version(),
|
|
213
|
+
converter: { inFormat: half.inFormat, outFormat: half.outFormat }
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
const res = await call(`${s.url}/api/contracts/traces/${encodeURIComponent(traceId)}/half`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${s.apiKey}` },
|
|
219
|
+
body
|
|
220
|
+
});
|
|
221
|
+
// 409 means intact already holds this half: the work is done, a retry would only repeat it.
|
|
222
|
+
if (res.ok || res.status === 409) stats.done++;
|
|
223
|
+
else {
|
|
224
|
+
stats.failed++;
|
|
225
|
+
log(`half upload refused: HTTP ${res.status}`);
|
|
226
|
+
}
|
|
227
|
+
try { await res.arrayBuffer(); } catch {}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
stats.failed++;
|
|
230
|
+
log(`half upload failed: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function pump() {
|
|
235
|
+
try {
|
|
236
|
+
while (queue.length) {
|
|
237
|
+
const entry = queue.shift();
|
|
238
|
+
const s = active();
|
|
239
|
+
if (!s) {
|
|
240
|
+
stats.dropped++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
await send(s, entry);
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
log(`upload queue stopped: ${err.message}`);
|
|
247
|
+
} finally {
|
|
248
|
+
pumping = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Queues this side of a sampled exchange. Returns before any byte goes to intact. */
|
|
253
|
+
function upload(traceId, half) {
|
|
254
|
+
try {
|
|
255
|
+
if (!active() || !TRACE_ID_RE.test(String(traceId || ''))) return;
|
|
256
|
+
if (queue.length >= MAX_QUEUE) {
|
|
257
|
+
queue.shift();
|
|
258
|
+
stats.dropped++;
|
|
259
|
+
}
|
|
260
|
+
queue.push({ traceId, half });
|
|
261
|
+
if (!pumping) {
|
|
262
|
+
pumping = true;
|
|
263
|
+
setImmediate(() => { void pump(); });
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
traceFor,
|
|
270
|
+
upload,
|
|
271
|
+
policy: () => policy,
|
|
272
|
+
pending: () => queue.map(e => e.traceId),
|
|
273
|
+
stats: () => ({ ...stats, queued: queue.length })
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------- probe
|
|
278
|
+
//
|
|
279
|
+
// `switch contract-probe` drives one exchange per variant, format and model through the local
|
|
280
|
+
// gateway so intact sees a full matrix without a human typing in a coding tool. The gateway
|
|
281
|
+
// captures a probe exchange only when the request carries admin.token, which a coding tool
|
|
282
|
+
// never has: a client can still not choose a trace id.
|
|
283
|
+
|
|
284
|
+
export const PROBE_HEADER = 'x-intact-probe';
|
|
285
|
+
export const PROBE_FORMATS = ['anthropic', 'responses'];
|
|
286
|
+
export const PROBE_PATHS = { anthropic: '/v1/messages', responses: '/v1/responses' };
|
|
287
|
+
const PROBE_TIMEOUT_MS = 120_000;
|
|
288
|
+
// A thinking budget under 1024 is refused by the backends the matrix sampled (docs/LLM-RESPONSE-MATRIX.md).
|
|
289
|
+
const THINK_BUDGET = 2048;
|
|
290
|
+
const TRUNC_MAX_TOKENS = 40;
|
|
291
|
+
const PROBE_MAX_TOKENS = 512;
|
|
292
|
+
const PROBE_PROMPT = 'Think step by step, then answer in one sentence: why does ice float on water?';
|
|
293
|
+
const PROBE_TOOL_PROMPT = 'Call the get_weather tool for Hanoi. Answer only with the tool call.';
|
|
294
|
+
const PROBE_TOOL_SCHEMA = {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: { city: { type: 'string', description: 'City name' } },
|
|
297
|
+
required: ['city']
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const VARIANT_KNOBS = {
|
|
301
|
+
'base-stream': { stream: true },
|
|
302
|
+
'think-stream': { stream: true, think: true },
|
|
303
|
+
'think-nostream': { stream: false, think: true },
|
|
304
|
+
'tool-stream': { stream: true, tool: true },
|
|
305
|
+
'trunc-stream': { stream: true, maxTokens: TRUNC_MAX_TOKENS },
|
|
306
|
+
'effort-stream': { stream: true, effort: 'high' }
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** The variant names of docs/response-matrix.json that this probe can build a request for. */
|
|
310
|
+
export function probeVariants() {
|
|
311
|
+
try {
|
|
312
|
+
const file = path.join(ROOT_DIR, 'docs', 'response-matrix.json');
|
|
313
|
+
const listed = JSON.parse(fs.readFileSync(file, 'utf8')).variants;
|
|
314
|
+
const usable = Array.isArray(listed) ? listed.filter(v => Object.hasOwn(VARIANT_KNOBS, v)) : [];
|
|
315
|
+
if (usable.length) return usable;
|
|
316
|
+
} catch {}
|
|
317
|
+
return Object.keys(VARIANT_KNOBS);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The path and body of one probe request. An unknown variant is built as the base one. */
|
|
321
|
+
export function probeRequest({ format, variant, model }) {
|
|
322
|
+
const knobs = VARIANT_KNOBS[variant] || VARIANT_KNOBS['base-stream'];
|
|
323
|
+
const maxTokens = knobs.maxTokens || PROBE_MAX_TOKENS;
|
|
324
|
+
const prompt = knobs.tool ? PROBE_TOOL_PROMPT : PROBE_PROMPT;
|
|
325
|
+
if (format === 'responses') {
|
|
326
|
+
return {
|
|
327
|
+
path: PROBE_PATHS.responses,
|
|
328
|
+
body: {
|
|
329
|
+
model,
|
|
330
|
+
input: prompt,
|
|
331
|
+
stream: knobs.stream === true,
|
|
332
|
+
max_output_tokens: maxTokens,
|
|
333
|
+
...(knobs.think ? { reasoning: { effort: 'medium' } } : {}),
|
|
334
|
+
...(knobs.effort ? { reasoning: { effort: knobs.effort } } : {}),
|
|
335
|
+
...(knobs.tool ? { tools: [{ type: 'function', name: 'get_weather', description: 'Current weather of a city', parameters: PROBE_TOOL_SCHEMA }] } : {})
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// Anthropic has no reasoning_effort: the effort variant asks for the deepest thinking instead.
|
|
340
|
+
// Anthropic requires max_tokens to strictly exceed budget_tokens.
|
|
341
|
+
const think = knobs.think || knobs.effort;
|
|
342
|
+
const budget = knobs.effort ? THINK_BUDGET * 2 : THINK_BUDGET;
|
|
343
|
+
const anthropicMaxTokens = think ? Math.max(maxTokens, budget + PROBE_MAX_TOKENS) : maxTokens;
|
|
344
|
+
return {
|
|
345
|
+
path: PROBE_PATHS.anthropic,
|
|
346
|
+
body: {
|
|
347
|
+
model,
|
|
348
|
+
max_tokens: anthropicMaxTokens,
|
|
349
|
+
messages: [{ role: 'user', content: prompt }],
|
|
350
|
+
stream: knobs.stream === true,
|
|
351
|
+
...(think ? { thinking: { type: 'enabled', budget_tokens: budget } } : {}),
|
|
352
|
+
...(knobs.tool ? { tools: [{ name: 'get_weather', description: 'Current weather of a city', input_schema: PROBE_TOOL_SCHEMA }] } : {})
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Every model an active profile maps to, in slot order, once each. */
|
|
358
|
+
export function probeModels(cfg) {
|
|
359
|
+
const out = [];
|
|
360
|
+
const seenProfiles = new Set();
|
|
361
|
+
for (const key of Object.values(getActiveMap(cfg))) {
|
|
362
|
+
if (!key || seenProfiles.has(key)) continue;
|
|
363
|
+
seenProfiles.add(key);
|
|
364
|
+
const profile = cfg?.profiles?.[key];
|
|
365
|
+
if (!profile) continue;
|
|
366
|
+
for (const slot of modelSlotsForProfile(profile)) {
|
|
367
|
+
const model = modelForSlot(profile, slot);
|
|
368
|
+
if (model && !out.includes(model)) out.push(model);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Sends every variant of every format for every probed model through the local gateway.
|
|
376
|
+
* Returns one row per request; `unreachable` is true when the gateway refused a connection.
|
|
377
|
+
*/
|
|
378
|
+
export async function runProbe(options = {}) {
|
|
379
|
+
const {
|
|
380
|
+
port, token, config, model = '', fetchImpl = (...args) => fetch(...args),
|
|
381
|
+
log = (line) => console.log(line), newId = newTraceId
|
|
382
|
+
} = options;
|
|
383
|
+
const wanted = String(model || '').toLowerCase();
|
|
384
|
+
const models = probeModels(config).filter(m => !wanted || m.toLowerCase() === wanted);
|
|
385
|
+
const variants = probeVariants();
|
|
386
|
+
const rows = [];
|
|
387
|
+
let unreachable = false;
|
|
388
|
+
|
|
389
|
+
for (const target of models) {
|
|
390
|
+
for (const format of PROBE_FORMATS) {
|
|
391
|
+
for (const variant of variants) {
|
|
392
|
+
const traceId = newId();
|
|
393
|
+
const { path: route, body } = probeRequest({ format, variant, model: target });
|
|
394
|
+
let status = 'unreachable';
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetchImpl(`http://127.0.0.1:${port}${route}`, {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: {
|
|
399
|
+
'content-type': 'application/json',
|
|
400
|
+
'user-agent': `llm-switcher-probe/${switcherVersion()}`,
|
|
401
|
+
'x-llm-switcher-token': token,
|
|
402
|
+
[PROBE_HEADER]: traceId
|
|
403
|
+
},
|
|
404
|
+
body: JSON.stringify(body),
|
|
405
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
406
|
+
});
|
|
407
|
+
status = res.status;
|
|
408
|
+
try { await res.arrayBuffer(); } catch {}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
unreachable = true;
|
|
411
|
+
status = `unreachable (${err.message})`;
|
|
412
|
+
}
|
|
413
|
+
rows.push({ model: target, format, variant, traceId, status });
|
|
414
|
+
log(`${target} ${format} ${variant} ${traceId} ${status}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { rows, unreachable };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------- check
|
|
422
|
+
//
|
|
423
|
+
// `switch contract-check` turns the open findings of intact into test DATA. Nothing that a
|
|
424
|
+
// finding or a fixture holds is ever written into a `.mjs` file: a path, an enum value or a name
|
|
425
|
+
// comes from a provider or from another key, and only the finding id (validated against
|
|
426
|
+
// FINDING_ID_RE) ever builds a file name.
|
|
427
|
+
|
|
428
|
+
export const FINDING_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
429
|
+
export const CONTRACT_TEST_DIR = path.join(ROOT_DIR, 'tests', 'contract');
|
|
430
|
+
const EXCLUSIONS_FILE = path.join(ROOT_DIR, 'contract-exclusions.json');
|
|
431
|
+
const CHECK_TIMEOUT_MS = 30_000;
|
|
432
|
+
|
|
433
|
+
/** Untrusted text on its way to the terminal: printable, one line, short. */
|
|
434
|
+
const safe = (v) => String(v ?? '').replace(/[^\x20-\x7e]/g, '?').slice(0, 64);
|
|
435
|
+
|
|
436
|
+
/** The intentional normalizations of the converter. A broken file excludes nothing. */
|
|
437
|
+
export function loadExclusions(file = EXCLUSIONS_FILE) {
|
|
438
|
+
try {
|
|
439
|
+
const list = JSON.parse(fs.readFileSync(file, 'utf8')).exclusions;
|
|
440
|
+
return Array.isArray(list) ? list.filter(e => e && typeof e.id === 'string' && Array.isArray(e.paths)) : [];
|
|
441
|
+
} catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The exclusion that covers this finding, or null. A path matches exactly, in its direction. */
|
|
447
|
+
export function matchExclusion(finding, list = loadExclusions()) {
|
|
448
|
+
const path_ = String(finding?.path ?? '');
|
|
449
|
+
const direction = String(finding?.direction ?? '');
|
|
450
|
+
for (const item of list) {
|
|
451
|
+
if (item.direction && item.direction !== direction) continue;
|
|
452
|
+
if (item.paths.includes(path_)) return item;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function getJson(url, apiKey, fetchImpl) {
|
|
458
|
+
const res = await fetchImpl(url, {
|
|
459
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
460
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
|
|
461
|
+
});
|
|
462
|
+
if (res.status === 404) return { status: 404, body: null };
|
|
463
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).pathname}`);
|
|
464
|
+
return { status: res.status, body: await res.json() };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Pulls the open findings and the fixture of each exempt trace, and writes one JSON file per
|
|
469
|
+
* `lost` finding that no exclusion covers. Returns the printed rows; `ok` is false when intact
|
|
470
|
+
* could not be read, which the CLI turns into exit code 2.
|
|
471
|
+
*/
|
|
472
|
+
export async function runCheck(options = {}) {
|
|
473
|
+
const {
|
|
474
|
+
settings = () => null,
|
|
475
|
+
fetchImpl = (...args) => fetch(...args),
|
|
476
|
+
dir = CONTRACT_TEST_DIR,
|
|
477
|
+
exclusions = loadExclusions(),
|
|
478
|
+
log = (line) => console.log(line)
|
|
479
|
+
} = options;
|
|
480
|
+
|
|
481
|
+
const s = activeSettings(settings());
|
|
482
|
+
if (!s) return { ok: false, rows: [], error: 'The contract lab is off. Set contractLab.url, contractLab.apiKey and contractLab.enabled in config.json.' };
|
|
483
|
+
|
|
484
|
+
let findings;
|
|
485
|
+
try {
|
|
486
|
+
const pulled = await getJson(`${s.url}/api/contracts/findings?status=open`, s.apiKey, fetchImpl);
|
|
487
|
+
const body = pulled.body;
|
|
488
|
+
findings = Array.isArray(body) ? body : Array.isArray(body?.findings) ? body.findings : null;
|
|
489
|
+
if (!findings) throw new Error('the findings route answered an unreadable body');
|
|
490
|
+
} catch (err) {
|
|
491
|
+
return { ok: false, rows: [], error: `intact did not answer: ${err.message}` };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const rows = [];
|
|
495
|
+
const writtenFiles = new Set();
|
|
496
|
+
for (const raw of findings) {
|
|
497
|
+
const finding = raw && typeof raw === 'object' ? raw : {};
|
|
498
|
+
const id = String(finding.id ?? '');
|
|
499
|
+
const row = {
|
|
500
|
+
id: FINDING_ID_RE.test(id) ? id : '<invalid id>',
|
|
501
|
+
model: safe(finding.model),
|
|
502
|
+
direction: safe(finding.direction),
|
|
503
|
+
class: safe(finding.class),
|
|
504
|
+
file: '-'
|
|
505
|
+
};
|
|
506
|
+
rows.push(row);
|
|
507
|
+
|
|
508
|
+
if (!FINDING_ID_RE.test(id)) {
|
|
509
|
+
row.skipped = true;
|
|
510
|
+
row.file = 'skipped: the id is not one url-safe word';
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (finding.class !== 'lost') continue;
|
|
514
|
+
if (matchExclusion(finding, exclusions)) {
|
|
515
|
+
row.file = 'excluded';
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const trace = String(finding.exemptTrace ?? '');
|
|
519
|
+
if (!trace) {
|
|
520
|
+
row.file = 'no fixture';
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
let fixture;
|
|
524
|
+
try {
|
|
525
|
+
const pulled = await getJson(`${s.url}/api/contracts/fixtures/${encodeURIComponent(trace)}`, s.apiKey, fetchImpl);
|
|
526
|
+
if (pulled.status === 404 || !pulled.body) {
|
|
527
|
+
row.file = 'no fixture';
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
fixture = pulled.body;
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return { ok: false, rows, error: `intact did not answer: ${err.message}` };
|
|
533
|
+
}
|
|
534
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
535
|
+
const targetFile = path.join(dir, `${id}.json`);
|
|
536
|
+
const tmpFile = path.join(dir, `.${id}.json.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
537
|
+
fs.writeFileSync(tmpFile, `${JSON.stringify({ finding, fixture }, null, 2)}\n`);
|
|
538
|
+
fs.renameSync(tmpFile, targetFile);
|
|
539
|
+
writtenFiles.add(`${id}.json`);
|
|
540
|
+
row.file = `tests/contract/${id}.json`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Prune stale fixtures: only files written by this successful run remain in the directory.
|
|
544
|
+
if (fs.existsSync(dir)) {
|
|
545
|
+
try {
|
|
546
|
+
for (const name of fs.readdirSync(dir)) {
|
|
547
|
+
if (name.endsWith('.json') && !writtenFiles.has(name)) {
|
|
548
|
+
try { fs.unlinkSync(path.join(dir, name)); } catch {}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} catch {}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const width = (key, head) => Math.max(head.length, ...rows.map(r => String(r[key]).length), 0);
|
|
555
|
+
const cols = [['id', 'FINDING'], ['model', 'MODEL'], ['direction', 'DIRECTION'], ['class', 'CLASS'], ['file', 'FILE']];
|
|
556
|
+
const line = (get) => cols.map(([key, head]) => String(get(key, head)).padEnd(width(key, head))).join(' ').trimEnd();
|
|
557
|
+
log(line((key, head) => head));
|
|
558
|
+
for (const row of rows) log(line(key => row[key]));
|
|
559
|
+
log(`${rows.length} open findings, ${rows.filter(r => r.file.startsWith('tests/')).length} tests written.`);
|
|
560
|
+
return { ok: true, rows };
|
|
561
|
+
}
|