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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// One fixed runner for every contract finding. `switch contract-check` writes only JSON files
|
|
2
|
+
// beside this one; this file never changes, so no text of a finding or a fixture ever becomes
|
|
3
|
+
// JavaScript. With no JSON file it passes with zero cases.
|
|
4
|
+
//
|
|
5
|
+
// Each case rebuilds a payload from the recorded SHAPE: a unique marker per string leaf, padded
|
|
6
|
+
// to the recorded length. The case passes when the marker of the finding's path survives the
|
|
7
|
+
// converter. The shape of a fixture and of a finding is documented in docs/contracts.md of intact.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import {
|
|
14
|
+
parseToIR, emitUpstreamBody, healAnthropicPayload,
|
|
15
|
+
createUpstreamNormalizer, createCollector, splitThinkTags,
|
|
16
|
+
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage
|
|
17
|
+
} from '../../formats.mjs';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const CASE_DIR = process.env.LLM_SWITCHER_CONTRACT_DIR || HERE;
|
|
21
|
+
|
|
22
|
+
const markerFor = (index, len) => {
|
|
23
|
+
const base = `LSWMARKER${index}Z`;
|
|
24
|
+
return base.length >= len ? base : base + 'x'.repeat(len - base.length);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
28
|
+
|
|
29
|
+
function assertSafePath(p) {
|
|
30
|
+
for (const seg of String(p || '').split('.')) {
|
|
31
|
+
const key = seg.replace(/(?:\[\])*$/, '');
|
|
32
|
+
if (FORBIDDEN_SEGMENTS.has(key)) {
|
|
33
|
+
throw new Error(`Forbidden segment in path "${p}": "${key}" may pollute prototype`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The drift path syntax: `messages[].content[].text`. One `[]` is one array level, and every
|
|
39
|
+
// array is rebuilt with a single element, so leaves that share a prefix share that element.
|
|
40
|
+
function parsePath(p) {
|
|
41
|
+
assertSafePath(p);
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (const segment of String(p).split('.')) {
|
|
44
|
+
const m = /^(.*?)((?:\[\])*)$/.exec(segment);
|
|
45
|
+
if (m[1]) parts.push({ key: m[1] });
|
|
46
|
+
for (let i = 0; i < m[2].length / 2; i++) parts.push({ index: 0 });
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setAt(root, rawPath, value) {
|
|
52
|
+
const parts = parsePath(rawPath);
|
|
53
|
+
if (!parts.length) return;
|
|
54
|
+
let cur = root;
|
|
55
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
56
|
+
const key = parts[i].index ?? parts[i].key;
|
|
57
|
+
if (cur[key] === null || typeof cur[key] !== 'object') cur[key] = parts[i + 1].index === undefined ? {} : [];
|
|
58
|
+
cur = cur[key];
|
|
59
|
+
}
|
|
60
|
+
const last = parts.at(-1);
|
|
61
|
+
cur[last.index ?? last.key] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readAt(root, rawPath) {
|
|
65
|
+
let cur = root;
|
|
66
|
+
for (const part of parsePath(rawPath)) {
|
|
67
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
68
|
+
cur = cur[part.index ?? part.key];
|
|
69
|
+
}
|
|
70
|
+
return cur;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// An enum leaf keeps its recorded value, because the converter reads it (`type`, `role`,
|
|
74
|
+
// `stop_reason`). Every other leaf gets a value that appears nowhere else in the payload.
|
|
75
|
+
function valueOf(lf, index, chunk) {
|
|
76
|
+
const type = String(lf.type || 'string');
|
|
77
|
+
if (type === 'number') return 1000 + index;
|
|
78
|
+
if (type === 'bool' || type === 'boolean') return true;
|
|
79
|
+
if (type === 'null') return null;
|
|
80
|
+
if (type === 'object') return {};
|
|
81
|
+
if (type === 'array') return [];
|
|
82
|
+
if (lf.enum) return lf.enum;
|
|
83
|
+
return chunk ?? markerFor(index, Number(lf.len) || 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One document per recorded event, and one document per delta of an accumulated leaf. */
|
|
87
|
+
function buildDocs(records, markers) {
|
|
88
|
+
const docs = [];
|
|
89
|
+
let index = 0;
|
|
90
|
+
for (const record of records || []) {
|
|
91
|
+
const leaves = (record.leaves || []).filter(lf => lf && lf.path && lf.type !== 'cut');
|
|
92
|
+
const numbered = leaves.map(lf => ({ lf, index: index++ }));
|
|
93
|
+
for (const { lf, index: i } of numbered) {
|
|
94
|
+
const type = lf.type || 'string';
|
|
95
|
+
if (!lf.enum) {
|
|
96
|
+
if (type === 'string') {
|
|
97
|
+
markers.set(lf.path, markerFor(i, Number(lf.len) || 0));
|
|
98
|
+
} else if (type === 'number') {
|
|
99
|
+
markers.set(lf.path, 1000 + i);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const deltas = Math.max(1, ...numbered.map(({ lf }) => Number(lf.deltas) || 1));
|
|
104
|
+
for (let d = 0; d < deltas; d++) {
|
|
105
|
+
const doc = {};
|
|
106
|
+
let wrote = false;
|
|
107
|
+
for (const { lf, index: i } of numbered) {
|
|
108
|
+
const own = Math.max(1, Number(lf.deltas) || 1);
|
|
109
|
+
if (own === 1 && d > 0 && !lf.enum) continue;
|
|
110
|
+
let chunk;
|
|
111
|
+
if (own > 1) {
|
|
112
|
+
const whole = markers.get(lf.path) ?? markerFor(i, Number(lf.len) || 0);
|
|
113
|
+
const size = Math.ceil(whole.length / own);
|
|
114
|
+
chunk = whole.slice(d * size, (d + 1) * size);
|
|
115
|
+
}
|
|
116
|
+
setAt(doc, lf.path, valueOf(lf, i, chunk));
|
|
117
|
+
wrote = true;
|
|
118
|
+
}
|
|
119
|
+
if (wrote) docs.push(doc);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return docs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clientMessage(clientFormat, args) {
|
|
126
|
+
if (clientFormat === 'anthropic') return buildAnthropicMessage(args);
|
|
127
|
+
if (clientFormat === 'responses') return buildResponsesMessage(args);
|
|
128
|
+
if (clientFormat === 'vertex') return buildVertexMessage(args);
|
|
129
|
+
return buildChatMessage(args);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Request: the tool's request becomes the upstream request. Response: the upstream answer, event
|
|
133
|
+
// by event, becomes the answer the tool receives.
|
|
134
|
+
function convert(fixture, direction, docs) {
|
|
135
|
+
const model = String(fixture.model || 'contract-model');
|
|
136
|
+
const clientFormat = String(fixture.clientFormat || 'anthropic');
|
|
137
|
+
const upstreamFormat = String(fixture.upstreamFormat || 'openai-chat');
|
|
138
|
+
if (direction === 'request') {
|
|
139
|
+
const payload = docs[0] || {};
|
|
140
|
+
if (clientFormat === 'anthropic' && upstreamFormat === 'anthropic') return healAnthropicPayload(payload).payload;
|
|
141
|
+
return emitUpstreamBody(upstreamFormat, parseToIR(clientFormat, payload), model);
|
|
142
|
+
}
|
|
143
|
+
const normalize = createUpstreamNormalizer(upstreamFormat);
|
|
144
|
+
const col = createCollector();
|
|
145
|
+
for (const doc of docs) col.add(normalize(doc));
|
|
146
|
+
const split = splitThinkTags(col.text.join(''));
|
|
147
|
+
return clientMessage(clientFormat, {
|
|
148
|
+
model,
|
|
149
|
+
think: [...col.think, split.think].filter(Boolean),
|
|
150
|
+
text: [split.text],
|
|
151
|
+
tools: [...col.tools.values()].sort((a, b) => a.index - b.index),
|
|
152
|
+
finish: col.finish,
|
|
153
|
+
prompt: col.prompt,
|
|
154
|
+
completion: col.completion(),
|
|
155
|
+
cached: col.cached,
|
|
156
|
+
reasoning: col.reasoning,
|
|
157
|
+
sig: col.sig
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function caseFiles() {
|
|
162
|
+
try {
|
|
163
|
+
return fs.readdirSync(CASE_DIR).filter(n => n.endsWith('.json')).sort();
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test('every contract finding replays through the converter', async (t) => {
|
|
170
|
+
for (const name of caseFiles()) {
|
|
171
|
+
await t.test(name, (sub) => {
|
|
172
|
+
const { finding, fixture } = JSON.parse(fs.readFileSync(path.join(CASE_DIR, name), 'utf8'));
|
|
173
|
+
assertSafePath(finding.path);
|
|
174
|
+
if (finding.mapping) assertSafePath(finding.mapping);
|
|
175
|
+
const direction = String(finding.direction || 'request');
|
|
176
|
+
// The input of the diff: the tool's request for one direction, the upstream answer for the other.
|
|
177
|
+
const wantHalf = direction === 'request' ? 'switcher' : 'intact';
|
|
178
|
+
const half = (fixture.halves || []).find(h => h.half === wantHalf && h.direction === direction);
|
|
179
|
+
assert.ok(half, `the fixture has no ${wantHalf} half of the ${direction} direction`);
|
|
180
|
+
|
|
181
|
+
let targetLeaf = null;
|
|
182
|
+
for (const rec of half.records || []) {
|
|
183
|
+
for (const lf of rec.leaves || []) {
|
|
184
|
+
if (lf && lf.path === finding.path) {
|
|
185
|
+
targetLeaf = lf;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (targetLeaf) break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (targetLeaf) {
|
|
193
|
+
const leafType = String(targetLeaf.type || 'string');
|
|
194
|
+
if (targetLeaf.enum || ['bool', 'boolean', 'null', 'object', 'array'].includes(leafType)) {
|
|
195
|
+
sub.skip(`skipped: unsupported leaf type "${targetLeaf.enum ? 'enum' : leafType}" for finding ${finding.id}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const markers = new Map();
|
|
201
|
+
const docs = buildDocs(half.records, markers);
|
|
202
|
+
const marker = markers.get(finding.path);
|
|
203
|
+
assert.ok(marker !== undefined, `the fixture has no leaf at ${JSON.stringify(finding.path)}`);
|
|
204
|
+
|
|
205
|
+
const out = convert(fixture, direction, docs);
|
|
206
|
+
if (finding.class === 'renamed' && finding.mapping) {
|
|
207
|
+
const got = readAt(out, finding.mapping);
|
|
208
|
+
assert.ok(String(got ?? '').includes(String(marker)), `${finding.id}: the value of the renamed field is not at its target path`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
assert.ok(JSON.stringify(out).includes(String(marker)), `${finding.id}: the converter loses this field`);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// `switch contract-check`: it turns open findings of intact into JSON test data, and never into
|
|
2
|
+
// JavaScript. Every path, enum value and name in a finding or a fixture is untrusted text, so the
|
|
3
|
+
// hostile-finding test hashes every .mjs file of the repository before and after a run.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { runCheck, loadExclusions, matchExclusion, FINDING_ID_RE } from '../contract.mjs';
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const HOSTILE = "x');require('child_process')";
|
|
17
|
+
|
|
18
|
+
function tmpDir(name) {
|
|
19
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One fake intact: it serves the findings list and one fixture per known trace id.
|
|
23
|
+
function fakeIntact({ findings = [], fixtures = {} } = {}) {
|
|
24
|
+
const seen = [];
|
|
25
|
+
const server = http.createServer((req, res) => {
|
|
26
|
+
seen.push(req.url);
|
|
27
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
28
|
+
const send = (status, body) => {
|
|
29
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
30
|
+
res.end(JSON.stringify(body));
|
|
31
|
+
};
|
|
32
|
+
if (url.pathname === '/api/contracts/findings') {
|
|
33
|
+
if (url.searchParams.get('status') !== 'open') return send(400, { error: 'status must be open' });
|
|
34
|
+
return send(200, { findings });
|
|
35
|
+
}
|
|
36
|
+
const fixture = /^\/api\/contracts\/fixtures\/(.+)$/.exec(url.pathname);
|
|
37
|
+
if (fixture) {
|
|
38
|
+
const body = fixtures[decodeURIComponent(fixture[1])];
|
|
39
|
+
return body ? send(200, body) : send(404, { error: 'no fixture' });
|
|
40
|
+
}
|
|
41
|
+
send(404, { error: 'not found' });
|
|
42
|
+
});
|
|
43
|
+
return new Promise(resolve => {
|
|
44
|
+
server.listen(0, '127.0.0.1', () => {
|
|
45
|
+
const { port } = server.address();
|
|
46
|
+
resolve({
|
|
47
|
+
server,
|
|
48
|
+
seen,
|
|
49
|
+
settings: () => ({ url: `http://127.0.0.1:${port}`, apiKey: 'k', enabled: true }),
|
|
50
|
+
close: () => new Promise(r => server.close(r))
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const leaf = (p, extra = {}) => ({ path: p, type: 'string', len: 12, enum: '', deltas: 1, ...extra });
|
|
57
|
+
|
|
58
|
+
// A fixture of one Anthropic request: the text block survives the converter, `metadata.user_id`
|
|
59
|
+
// does not, so the same shape proves both a passing and a failing replay.
|
|
60
|
+
function anthropicRequestFixture(traceId, extraLeaves = []) {
|
|
61
|
+
return {
|
|
62
|
+
traceId,
|
|
63
|
+
model: 'up-sonnet',
|
|
64
|
+
clientFormat: 'anthropic',
|
|
65
|
+
upstreamFormat: 'openai-chat',
|
|
66
|
+
halves: [
|
|
67
|
+
{
|
|
68
|
+
half: 'switcher',
|
|
69
|
+
direction: 'request',
|
|
70
|
+
records: [{
|
|
71
|
+
event: '',
|
|
72
|
+
leaves: [
|
|
73
|
+
leaf('model', { enum: 'up-sonnet' }),
|
|
74
|
+
leaf('max_tokens', { type: 'number' }),
|
|
75
|
+
leaf('messages[].role', { enum: 'user' }),
|
|
76
|
+
leaf('messages[].content[].type', { enum: 'text' }),
|
|
77
|
+
leaf('messages[].content[].text', { len: 24 }),
|
|
78
|
+
...extraLeaves
|
|
79
|
+
]
|
|
80
|
+
}]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const finding = (over = {}) => ({
|
|
87
|
+
id: 'f-1',
|
|
88
|
+
model: 'up-sonnet',
|
|
89
|
+
clientFormat: 'anthropic',
|
|
90
|
+
direction: 'request',
|
|
91
|
+
path: 'metadata.user_id',
|
|
92
|
+
class: 'lost',
|
|
93
|
+
status: 'open',
|
|
94
|
+
exemptTrace: 't-1',
|
|
95
|
+
...over
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function mjsHashes() {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const name of fs.readdirSync(ROOT)) {
|
|
101
|
+
if (name.endsWith('.mjs')) out[name] = crypto.createHash('sha256').update(fs.readFileSync(path.join(ROOT, name))).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
for (const dir of ['tests', 'blindfold', 'skills']) {
|
|
104
|
+
const full = path.join(ROOT, dir);
|
|
105
|
+
if (!fs.existsSync(full)) continue;
|
|
106
|
+
for (const name of fs.readdirSync(full)) {
|
|
107
|
+
if (name.endsWith('.mjs')) out[`${dir}/${name}`] = crypto.createHash('sha256').update(fs.readFileSync(path.join(full, name))).digest('hex');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- exclusions
|
|
114
|
+
|
|
115
|
+
test('the exclusion list names the intentional normalizations of the converter', () => {
|
|
116
|
+
const list = loadExclusions();
|
|
117
|
+
assert.ok(Array.isArray(list) && list.length >= 2, 'the list is read from contract-exclusions.json');
|
|
118
|
+
for (const item of list) {
|
|
119
|
+
assert.match(item.id, /^[a-z0-9-]{1,64}$/);
|
|
120
|
+
assert.ok(item.reason.length > 20, `${item.id} must say why it is intentional`);
|
|
121
|
+
assert.ok(Array.isArray(item.paths) && item.paths.length, `${item.id} must name paths`);
|
|
122
|
+
}
|
|
123
|
+
const reasons = list.map(i => i.reason).join(' ');
|
|
124
|
+
assert.match(reasons, /healToolPairs/);
|
|
125
|
+
assert.match(reasons, /signature/i);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('an excluded path matches only in its own direction', () => {
|
|
129
|
+
const list = loadExclusions();
|
|
130
|
+
const placeholder = list.find(i => /healToolPairs/.test(i.reason));
|
|
131
|
+
const p = placeholder.paths[0];
|
|
132
|
+
assert.ok(matchExclusion({ path: p, direction: 'request' }, list), 'the listed path is excluded');
|
|
133
|
+
assert.equal(matchExclusion({ path: p, direction: 'response' }, list), null, 'the other direction is not');
|
|
134
|
+
assert.equal(matchExclusion({ path: `${p}.not_listed`, direction: 'request' }, list), null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a finding id is one url-safe word, never a path', () => {
|
|
138
|
+
assert.match('f-1', FINDING_ID_RE);
|
|
139
|
+
assert.match('A_9-z', FINDING_ID_RE);
|
|
140
|
+
assert.equal(FINDING_ID_RE.test('../../formats.mjs'), false);
|
|
141
|
+
assert.equal(FINDING_ID_RE.test(HOSTILE), false);
|
|
142
|
+
assert.equal(FINDING_ID_RE.test(''), false);
|
|
143
|
+
assert.equal(FINDING_ID_RE.test('a'.repeat(65)), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- the check
|
|
147
|
+
|
|
148
|
+
test('every open lost finding with a fixture becomes one JSON file, and the table names it', async () => {
|
|
149
|
+
const excluded = loadExclusions()[0];
|
|
150
|
+
const lab = await fakeIntact({
|
|
151
|
+
findings: [
|
|
152
|
+
finding(),
|
|
153
|
+
finding({ id: 'f-excluded', path: excluded.paths[0], direction: excluded.direction, exemptTrace: 't-1' }),
|
|
154
|
+
finding({ id: 'f-nofixture', exemptTrace: 't-missing' }),
|
|
155
|
+
finding({ id: 'f-renamed', class: 'renamed', mapping: 'thinking', exemptTrace: 't-1' }),
|
|
156
|
+
finding({ id: HOSTILE, exemptTrace: 't-1' })
|
|
157
|
+
],
|
|
158
|
+
fixtures: { 't-1': anthropicRequestFixture('t-1') }
|
|
159
|
+
});
|
|
160
|
+
const dir = tmpDir('check');
|
|
161
|
+
const lines = [];
|
|
162
|
+
try {
|
|
163
|
+
const out = await runCheck({ settings: lab.settings, dir, log: l => lines.push(l) });
|
|
164
|
+
assert.equal(out.ok, true, out.error);
|
|
165
|
+
assert.deepEqual(fs.readdirSync(dir), ['f-1.json'], 'only the lost finding with a fixture is written');
|
|
166
|
+
|
|
167
|
+
const written = JSON.parse(fs.readFileSync(path.join(dir, 'f-1.json'), 'utf8'));
|
|
168
|
+
assert.equal(written.finding.id, 'f-1');
|
|
169
|
+
assert.equal(written.fixture.traceId, 't-1');
|
|
170
|
+
|
|
171
|
+
const row = (id) => out.rows.find(r => r.id === id);
|
|
172
|
+
assert.equal(row('f-1').file, 'tests/contract/f-1.json');
|
|
173
|
+
assert.equal(row('f-excluded').file, 'excluded');
|
|
174
|
+
assert.equal(row('f-nofixture').file, 'no fixture');
|
|
175
|
+
assert.equal(row('f-renamed').file, '-');
|
|
176
|
+
assert.equal(out.rows.filter(r => r.skipped).length, 1, 'the hostile id is skipped');
|
|
177
|
+
|
|
178
|
+
const table = lines.join('\n');
|
|
179
|
+
assert.ok(table.includes('f-1') && table.includes('excluded') && table.includes('no fixture'), table);
|
|
180
|
+
assert.ok(table.includes('up-sonnet') && table.includes('request') && table.includes('lost'), table);
|
|
181
|
+
assert.equal(table.includes('require('), false, 'a hostile id is never printed verbatim');
|
|
182
|
+
assert.ok(lab.seen.some(u => u.includes('status=open')), 'only open findings are pulled');
|
|
183
|
+
} finally {
|
|
184
|
+
await lab.close();
|
|
185
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a hostile finding changes no .mjs file and lands in the JSON as plain data', async () => {
|
|
190
|
+
const hostilePath = `metadata.${HOSTILE}`;
|
|
191
|
+
const lab = await fakeIntact({
|
|
192
|
+
findings: [finding({ id: 'f-hostile', path: hostilePath })],
|
|
193
|
+
fixtures: { 't-1': anthropicRequestFixture('t-1', [leaf(hostilePath)]) }
|
|
194
|
+
});
|
|
195
|
+
const dir = tmpDir('hostile');
|
|
196
|
+
const before = mjsHashes();
|
|
197
|
+
try {
|
|
198
|
+
const out = await runCheck({ settings: lab.settings, dir, log: () => {} });
|
|
199
|
+
assert.equal(out.ok, true, out.error);
|
|
200
|
+
assert.deepEqual(mjsHashes(), before, 'no source file may change');
|
|
201
|
+
assert.deepEqual(fs.readdirSync(dir), ['f-hostile.json'], 'the file name is the validated id only');
|
|
202
|
+
const written = JSON.parse(fs.readFileSync(path.join(dir, 'f-hostile.json'), 'utf8'));
|
|
203
|
+
assert.equal(written.finding.path, hostilePath, 'the hostile text survives as data, unchanged');
|
|
204
|
+
} finally {
|
|
205
|
+
await lab.close();
|
|
206
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a finding whose fixture is 404 writes nothing, and a down intact is an error', async () => {
|
|
211
|
+
const lab = await fakeIntact({ findings: [finding({ exemptTrace: 'gone' })] });
|
|
212
|
+
const dir = tmpDir('nofix');
|
|
213
|
+
try {
|
|
214
|
+
const out = await runCheck({ settings: lab.settings, dir, log: () => {} });
|
|
215
|
+
assert.equal(out.ok, true);
|
|
216
|
+
assert.deepEqual(fs.readdirSync(dir), []);
|
|
217
|
+
assert.equal(out.rows[0].file, 'no fixture');
|
|
218
|
+
} finally {
|
|
219
|
+
await lab.close();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const down = await runCheck({ settings: () => ({ url: 'http://127.0.0.1:1', apiKey: 'k', enabled: true }), dir, log: () => {} });
|
|
223
|
+
assert.equal(down.ok, false);
|
|
224
|
+
assert.ok(down.error, 'an unreachable intact gives a message');
|
|
225
|
+
assert.deepEqual(fs.readdirSync(dir), [], 'a failed pull writes nothing');
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
|
|
228
|
+
const off = await runCheck({ settings: () => ({ url: '', apiKey: '', enabled: false }), dir: tmpDir('off'), log: () => {} });
|
|
229
|
+
assert.equal(off.ok, false, 'a lab that is not configured is an error, not an empty run');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('switch contract-check exits 2 when intact does not answer', async () => {
|
|
233
|
+
const dir = tmpDir('cli');
|
|
234
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
|
|
235
|
+
profiles: {}, activeProfiles: {},
|
|
236
|
+
contractLab: { url: 'http://127.0.0.1:1', apiKey: 'k', enabled: true }
|
|
237
|
+
}));
|
|
238
|
+
const env = { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'), LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude') };
|
|
239
|
+
const run = await new Promise(resolve => {
|
|
240
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'switch.mjs'), 'contract-check'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
241
|
+
let stdout = '';
|
|
242
|
+
let stderr = '';
|
|
243
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
244
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
245
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
246
|
+
});
|
|
247
|
+
assert.equal(run.status, 2, `${run.stdout}\n${run.stderr}`);
|
|
248
|
+
assert.match(run.stderr, /intact/i);
|
|
249
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------- the fixed runner
|
|
253
|
+
|
|
254
|
+
// The runner is one committed file. It is driven here over a directory of synthetic cases, so a
|
|
255
|
+
// real finding is never needed to prove that a lost field fails and a kept field passes.
|
|
256
|
+
function runRunner(dir) {
|
|
257
|
+
// NODE_TEST_CONTEXT makes a child report to its parent runner instead of setting its own exit
|
|
258
|
+
// code, and this test reads exactly that code.
|
|
259
|
+
const { NODE_TEST_CONTEXT, ...clean } = process.env;
|
|
260
|
+
return new Promise(resolve => {
|
|
261
|
+
const child = spawn(process.execPath, ['--test', path.join(ROOT, 'tests/contract/run.test.mjs')], {
|
|
262
|
+
cwd: ROOT,
|
|
263
|
+
env: { ...clean, LLM_SWITCHER_CONTRACT_DIR: dir },
|
|
264
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
265
|
+
});
|
|
266
|
+
let stdout = '';
|
|
267
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
268
|
+
child.stderr.on('data', d => { stdout += d; });
|
|
269
|
+
child.on('close', status => resolve({ status, stdout }));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test('the runner passes with no case, fails on a lost field and passes on a kept field', async () => {
|
|
274
|
+
const empty = tmpDir('runner-empty');
|
|
275
|
+
const emptyRun = await runRunner(empty);
|
|
276
|
+
assert.equal(emptyRun.status, 0, emptyRun.stdout);
|
|
277
|
+
|
|
278
|
+
const lost = tmpDir('runner-lost');
|
|
279
|
+
fs.writeFileSync(path.join(lost, 'f-lost.json'), JSON.stringify({
|
|
280
|
+
finding: finding({ id: 'f-lost', path: 'metadata.user_id' }),
|
|
281
|
+
fixture: anthropicRequestFixture('t-1', [leaf('metadata.user_id', { len: 20 })])
|
|
282
|
+
}));
|
|
283
|
+
const lostRun = await runRunner(lost);
|
|
284
|
+
assert.notEqual(lostRun.status, 0, 'a field the converter drops must fail');
|
|
285
|
+
assert.ok(lostRun.stdout.includes('f-lost'), lostRun.stdout);
|
|
286
|
+
|
|
287
|
+
const kept = tmpDir('runner-kept');
|
|
288
|
+
fs.writeFileSync(path.join(kept, 'f-kept.json'), JSON.stringify({
|
|
289
|
+
finding: finding({ id: 'f-kept', path: 'messages[].content[].text' }),
|
|
290
|
+
fixture: anthropicRequestFixture('t-1')
|
|
291
|
+
}));
|
|
292
|
+
const keptRun = await runRunner(kept);
|
|
293
|
+
assert.equal(keptRun.status, 0, keptRun.stdout);
|
|
294
|
+
|
|
295
|
+
for (const d of [empty, lost, kept]) fs.rmSync(d, { recursive: true, force: true });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('a renamed finding passes only when the value reaches its target path', async () => {
|
|
299
|
+
const fixture = {
|
|
300
|
+
traceId: 't-3',
|
|
301
|
+
model: 'up-sonnet',
|
|
302
|
+
clientFormat: 'anthropic',
|
|
303
|
+
upstreamFormat: 'openai-chat',
|
|
304
|
+
halves: [{
|
|
305
|
+
half: 'intact',
|
|
306
|
+
direction: 'response',
|
|
307
|
+
records: [{ event: '', leaves: [leaf('choices[].delta.reasoning_content', { len: 18 })] }]
|
|
308
|
+
}]
|
|
309
|
+
};
|
|
310
|
+
const base = finding({ direction: 'response', class: 'renamed', path: 'choices[].delta.reasoning_content' });
|
|
311
|
+
|
|
312
|
+
const good = tmpDir('runner-renamed-ok');
|
|
313
|
+
fs.writeFileSync(path.join(good, 'f-ren.json'), JSON.stringify({ finding: { ...base, id: 'f-ren', mapping: 'content[].thinking' }, fixture }));
|
|
314
|
+
const okRun = await runRunner(good);
|
|
315
|
+
assert.equal(okRun.status, 0, okRun.stdout);
|
|
316
|
+
|
|
317
|
+
const bad = tmpDir('runner-renamed-bad');
|
|
318
|
+
fs.writeFileSync(path.join(bad, 'f-ren-bad.json'), JSON.stringify({ finding: { ...base, id: 'f-ren-bad', mapping: 'content[].text' }, fixture }));
|
|
319
|
+
const badRun = await runRunner(bad);
|
|
320
|
+
assert.notEqual(badRun.status, 0, 'the wrong target path must fail');
|
|
321
|
+
|
|
322
|
+
for (const d of [good, bad]) fs.rmSync(d, { recursive: true, force: true });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the runner rebuilds a stream event by event and splits an accumulated path into its deltas', async () => {
|
|
326
|
+
const dir = tmpDir('runner-stream');
|
|
327
|
+
const fixture = {
|
|
328
|
+
traceId: 't-2',
|
|
329
|
+
model: 'up-sonnet',
|
|
330
|
+
clientFormat: 'anthropic',
|
|
331
|
+
upstreamFormat: 'openai-chat',
|
|
332
|
+
halves: [{
|
|
333
|
+
half: 'intact',
|
|
334
|
+
direction: 'response',
|
|
335
|
+
records: [{
|
|
336
|
+
event: '',
|
|
337
|
+
leaves: [
|
|
338
|
+
leaf('choices[].index', { type: 'number' }),
|
|
339
|
+
leaf('choices[].delta.content', { len: 30, deltas: 3 })
|
|
340
|
+
]
|
|
341
|
+
}]
|
|
342
|
+
}]
|
|
343
|
+
};
|
|
344
|
+
fs.writeFileSync(path.join(dir, 'f-stream.json'), JSON.stringify({
|
|
345
|
+
finding: finding({ id: 'f-stream', direction: 'response', path: 'choices[].delta.content' }),
|
|
346
|
+
fixture
|
|
347
|
+
}));
|
|
348
|
+
const run = await runRunner(dir);
|
|
349
|
+
assert.equal(run.status, 0, run.stdout);
|
|
350
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('a fixture path with prototype pollution segments fails safely', async () => {
|
|
354
|
+
const dir = tmpDir('runner-proto');
|
|
355
|
+
fs.writeFileSync(path.join(dir, 'f-proto.json'), JSON.stringify({
|
|
356
|
+
finding: finding({ id: 'f-proto', path: '__proto__.polluted' }),
|
|
357
|
+
fixture: anthropicRequestFixture('t-proto', [leaf('__proto__.polluted', { len: 10 })])
|
|
358
|
+
}));
|
|
359
|
+
const run = await runRunner(dir);
|
|
360
|
+
assert.notEqual(run.status, 0, 'prototype pollution path must fail');
|
|
361
|
+
assert.match(run.stdout, /__proto__|prototype|constructor/i, 'message must explain failure');
|
|
362
|
+
assert.equal(({}).polluted, undefined, 'Object.prototype must not be polluted');
|
|
363
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('a finding on a number leaf passes when kept and fails when dropped, and unsupported leaves are skipped', async () => {
|
|
367
|
+
const dir = tmpDir('runner-leaf-types');
|
|
368
|
+
|
|
369
|
+
// Kept number leaf: max_tokens in Anthropic request
|
|
370
|
+
fs.writeFileSync(path.join(dir, 'f-num-kept.json'), JSON.stringify({
|
|
371
|
+
finding: finding({ id: 'f-num-kept', path: 'max_tokens' }),
|
|
372
|
+
fixture: anthropicRequestFixture('t-num-1')
|
|
373
|
+
}));
|
|
374
|
+
const run1 = await runRunner(dir);
|
|
375
|
+
assert.equal(run1.status, 0, 'kept number leaf must pass');
|
|
376
|
+
|
|
377
|
+
// Dropped number leaf: metadata.dropped_num in Anthropic request
|
|
378
|
+
fs.writeFileSync(path.join(dir, 'f-num-dropped.json'), JSON.stringify({
|
|
379
|
+
finding: finding({ id: 'f-num-dropped', path: 'metadata.dropped_num' }),
|
|
380
|
+
fixture: anthropicRequestFixture('t-num-2', [leaf('metadata.dropped_num', { type: 'number' })])
|
|
381
|
+
}));
|
|
382
|
+
fs.rmSync(path.join(dir, 'f-num-kept.json'));
|
|
383
|
+
const run2 = await runRunner(dir);
|
|
384
|
+
assert.notEqual(run2.status, 0, 'dropped number leaf must fail');
|
|
385
|
+
|
|
386
|
+
// Unsupported leaf types: bool, null, enum, object, array -> should not fail the suite (skipped)
|
|
387
|
+
for (const t of ['bool', 'null', 'object', 'array']) {
|
|
388
|
+
fs.writeFileSync(path.join(dir, `f-skip-${t}.json`), JSON.stringify({
|
|
389
|
+
finding: finding({ id: `f-skip-${t}`, path: `metadata.${t}_field` }),
|
|
390
|
+
fixture: anthropicRequestFixture(`t-${t}`, [leaf(`metadata.${t}_field`, { type: t })])
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
fs.writeFileSync(path.join(dir, 'f-skip-enum.json'), JSON.stringify({
|
|
394
|
+
finding: finding({ id: 'f-skip-enum', path: 'metadata.enum_field' }),
|
|
395
|
+
fixture: anthropicRequestFixture('t-enum', [leaf('metadata.enum_field', { enum: 'some_val' })])
|
|
396
|
+
}));
|
|
397
|
+
fs.rmSync(path.join(dir, 'f-num-dropped.json'));
|
|
398
|
+
const run3 = await runRunner(dir);
|
|
399
|
+
assert.equal(run3.status, 0, 'cases on bool, null, enum, object or array leaves must not fail the suite');
|
|
400
|
+
|
|
401
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('a synthetic Anthropic stream case with delta.text deltas: 3 passes through a correct converter', async () => {
|
|
405
|
+
const dir = tmpDir('runner-anthropic-deltas');
|
|
406
|
+
const fixture = {
|
|
407
|
+
traceId: 't-ant-deltas',
|
|
408
|
+
model: 'up-sonnet',
|
|
409
|
+
clientFormat: 'anthropic',
|
|
410
|
+
upstreamFormat: 'anthropic',
|
|
411
|
+
halves: [{
|
|
412
|
+
half: 'intact',
|
|
413
|
+
direction: 'response',
|
|
414
|
+
records: [{
|
|
415
|
+
event: 'content_block_delta',
|
|
416
|
+
leaves: [
|
|
417
|
+
leaf('type', { enum: 'content_block_delta' }),
|
|
418
|
+
leaf('delta.type', { enum: 'text_delta' }),
|
|
419
|
+
leaf('delta.text', { len: 30, deltas: 3 })
|
|
420
|
+
]
|
|
421
|
+
}]
|
|
422
|
+
}]
|
|
423
|
+
};
|
|
424
|
+
const run = await runRunner(dir);
|
|
425
|
+
assert.equal(run.status, 0, run.stdout);
|
|
426
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('stale fixtures are removed on a successful check', async () => {
|
|
430
|
+
const lab = await fakeIntact({
|
|
431
|
+
findings: [finding({ id: 'f-fresh' })],
|
|
432
|
+
fixtures: { 't-1': anthropicRequestFixture('t-1') }
|
|
433
|
+
});
|
|
434
|
+
const dir = tmpDir('prune-ok');
|
|
435
|
+
try {
|
|
436
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
437
|
+
fs.writeFileSync(path.join(dir, 'stale.json'), '{"stale":true}');
|
|
438
|
+
fs.writeFileSync(path.join(dir, 'run.test.mjs'), '// runner must stay');
|
|
439
|
+
const out = await runCheck({ settings: lab.settings, dir, log: () => {} });
|
|
440
|
+
assert.equal(out.ok, true, out.error);
|
|
441
|
+
assert.equal(fs.existsSync(path.join(dir, 'stale.json')), false, 'stale.json must be removed');
|
|
442
|
+
assert.equal(fs.existsSync(path.join(dir, 'f-fresh.json')), true, 'f-fresh.json must exist');
|
|
443
|
+
assert.equal(fs.existsSync(path.join(dir, 'run.test.mjs')), true, 'run.test.mjs must never be touched');
|
|
444
|
+
} finally {
|
|
445
|
+
await lab.close();
|
|
446
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('a failed pull keeps existing fixtures including stale ones', async () => {
|
|
451
|
+
const dir = tmpDir('prune-fail');
|
|
452
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
453
|
+
fs.writeFileSync(path.join(dir, 'stale.json'), '{"stale":true}');
|
|
454
|
+
const down = await runCheck({ settings: () => ({ url: 'http://127.0.0.1:1', apiKey: 'k', enabled: true }), dir, log: () => {} });
|
|
455
|
+
assert.equal(down.ok, false);
|
|
456
|
+
assert.equal(fs.existsSync(path.join(dir, 'stale.json')), true, 'failed pull must delete nothing');
|
|
457
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
458
|
+
});
|