llm-switcher 1.6.5 → 1.6.7

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/proxy.mjs CHANGED
@@ -27,16 +27,32 @@ import {
27
27
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
28
  } from './state.mjs';
29
29
  import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
30
- import { createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
30
+ import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
31
+ import { readUpstreamPayloads } from './upstream-stream.mjs';
32
+ import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
31
33
  import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
32
34
  import { checkForUpdate } from './version.mjs';
33
35
  import { applyUpdate } from './update.mjs';
34
36
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
35
37
  import {
36
- idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
38
+ idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY, conversationKey
37
39
  } from './idlecompact.mjs';
38
40
  import { askSummary } from './idlecall.mjs';
39
41
  import { IdlePrefixCache, compactCacheKey, policyFingerprint } from './idlecache.mjs';
42
+ import {
43
+ GOAL_CHECK_KEY, goalCheckPolicy, validateGoalCheckPatch, redactGoalCheck,
44
+ isGoalEvaluatorRequest, buildBoundedEvidence, buildJevQuestions,
45
+ combineVerdicts, parseEvaluatorVerdict
46
+ } from './goalcheck.mjs';
47
+ import { callJev, testJevConnection, resolveJevTarget } from './goalcheck-call.mjs';
48
+
49
+ const MAX_GOAL_CHECK_LOGS = 64;
50
+ const goalCheckLogs = [];
51
+ const randStr = (n = 6) => crypto.randomBytes(Math.ceil(n / 2)).toString('hex').slice(0, n);
52
+ function logGoalCheck(entry) {
53
+ if (goalCheckLogs.length >= MAX_GOAL_CHECK_LOGS) goalCheckLogs.shift();
54
+ goalCheckLogs.push({ id: `gc_${Date.now()}_${randStr(4)}`, timestamp: Date.now(), ...entry });
55
+ }
40
56
 
41
57
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
58
  const uiHtmlPath = path.join(__dirname, 'ui.html');
@@ -454,53 +470,13 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
454
470
  return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
455
471
  }
456
472
 
457
- // Read the upstream stream: accept both SSE `data:` and raw JSON lines (Vertex framing).
458
- async function* readUpstreamPayloads(upstreamRes) {
459
- const reader = upstreamRes.body.getReader();
460
- const decoder = new TextDecoder('utf8');
461
- let buffer = '';
462
- let finished = false;
463
- try {
464
- while (true) {
465
- const { done, value } = await reader.read();
466
- if (done) {
467
- finished = true;
468
- break;
469
- }
470
- buffer += decoder.decode(value, { stream: true });
471
- const lines = buffer.split('\n');
472
- buffer = lines.pop();
473
- for (const line of lines) {
474
- const parsed = parsePayloadLine(line);
475
- if (parsed) yield parsed;
476
- }
477
- }
478
- buffer += decoder.decode();
479
- const tail = parsePayloadLine(buffer);
480
- if (tail) yield tail;
481
- } finally {
482
- // A consumer that stops early (an in-band error) must close the upstream connection too.
483
- if (!finished) await reader.cancel().catch(() => {});
484
- try { reader.releaseLock(); } catch {}
485
- }
486
- }
487
-
488
- function parsePayloadLine(line) {
489
- let s = line.trim();
490
- if (!s) return null;
491
- if (s.startsWith('data:')) s = s.slice(5).trim();
492
- else if (s.startsWith('event:') || s.startsWith(':') || s.startsWith('id:') || s.startsWith('retry:')) return null;
493
- if (!s || s === '[DONE]' || !s.startsWith('{')) return null;
494
- try { return JSON.parse(s); } catch { return null; }
495
- }
496
-
497
473
  // The tool vocabulary the caller declared, so a model that answers with a name from its own
498
474
  // training is written back in the caller's words instead of ending the turn with "No such tool
499
475
  // available". Built once per request from the tools the caller actually sent, so a request that
500
476
  // declared none pays nothing and a turn where the model agreed with the list changes nothing.
501
- function toolVocabFor(ir) {
477
+ function toolVocabFor(ir, outFormat) {
502
478
  if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
503
- if (!ir._vocab) ir._vocab = createToolVocab(ir.tools);
479
+ if (!ir._vocab) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
504
480
  return ir._vocab;
505
481
  }
506
482
 
@@ -887,6 +863,206 @@ async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, sig
887
863
  // once, so a slow or absent intact cannot reach the answer the client is waiting for.
888
864
  const contractLab = createContractLab({ settings: () => contractLabSettings(loadConfig()) });
889
865
 
866
+ function sendEvaluatorResponse(res, stream, verdict, model) {
867
+ const jsonText = JSON.stringify({
868
+ ok: Boolean(verdict.ok),
869
+ reason: String(verdict.reason || ''),
870
+ ...(verdict.impossible ? { impossible: true } : {})
871
+ });
872
+ const modelName = model || 'evaluator';
873
+ if (stream) {
874
+ res.writeHead(200, {
875
+ 'Content-Type': 'text/event-stream; charset=utf-8',
876
+ 'Cache-Control': 'no-cache',
877
+ 'Connection': 'keep-alive'
878
+ });
879
+ const msgId = `msg_${Date.now()}_${randStr(12)}`;
880
+ const emit = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
881
+
882
+ emit('message_start', {
883
+ type: 'message_start',
884
+ message: {
885
+ id: msgId,
886
+ type: 'message',
887
+ role: 'assistant',
888
+ model: modelName,
889
+ content: [],
890
+ stop_reason: null,
891
+ stop_sequence: null,
892
+ usage: { input_tokens: 0, output_tokens: 0 }
893
+ }
894
+ });
895
+ emit('content_block_start', {
896
+ type: 'content_block_start',
897
+ index: 0,
898
+ content_block: { type: 'text', text: '' }
899
+ });
900
+ emit('content_block_delta', {
901
+ type: 'content_block_delta',
902
+ index: 0,
903
+ delta: { type: 'text_delta', text: jsonText }
904
+ });
905
+ emit('content_block_stop', {
906
+ type: 'content_block_stop',
907
+ index: 0
908
+ });
909
+ emit('message_delta', {
910
+ type: 'message_delta',
911
+ delta: { stop_reason: 'end_turn', stop_sequence: null },
912
+ usage: { output_tokens: Math.max(1, Math.ceil(jsonText.length / 4)) }
913
+ });
914
+ emit('message_stop', {
915
+ type: 'message_stop'
916
+ });
917
+ res.end();
918
+ } else {
919
+ const msgId = `msg_${Date.now()}_${randStr(12)}`;
920
+ sendJson(res, 200, {
921
+ id: msgId,
922
+ type: 'message',
923
+ role: 'assistant',
924
+ model: modelName,
925
+ content: [{ type: 'text', text: jsonText }],
926
+ stop_reason: 'end_turn',
927
+ stop_sequence: null,
928
+ usage: { input_tokens: 0, output_tokens: Math.max(1, Math.ceil(jsonText.length / 4)) }
929
+ });
930
+ }
931
+ }
932
+
933
+ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profile, profileKey, policy, condition, opts, log }) {
934
+ const startTime = Date.now();
935
+ const sessionId = conversationKey(clientFormat, req, ir) || req.headers['x-claude-code-session-id'] || '';
936
+ const requestedModel = ir.model || payload.model || '';
937
+ const mappedModel = mapModel(requestedModel, profile, clientFormat);
938
+ const bifrost = BIFROST_FORMATS.has(clientFormat) && await crossesBifrost(req, profile, mappedModel);
939
+ const outFormat = resolveOutFormat(profile, mappedModel);
940
+
941
+ const gateAC = new AbortController();
942
+ const totalTimeout = Number.isFinite(policy.totalTimeoutMs) ? policy.totalTimeoutMs : 25000;
943
+ const gateTimer = setTimeout(() => gateAC.abort(), totalTimeout);
944
+ req.on('aborted', () => gateAC.abort());
945
+
946
+ const target = resolveJevTarget(policy, (loadConfig() || {}).profiles || {});
947
+ const evidence = buildBoundedEvidence(payload, policy.maxStateChars);
948
+ const questions = buildJevQuestions(condition);
949
+
950
+ let nativeVerdict = null;
951
+ let jevResult = null;
952
+
953
+ try {
954
+ const nativePromise = (async () => {
955
+ try {
956
+ let url, headers, body;
957
+ if (bifrost) {
958
+ url = bifrostURL(profile, clientFormat, req, opts);
959
+ headers = bifrostHeaders(req, profile);
960
+ const nativePayload = clientFormat === 'responses' ? expandGatewayCompactions(payload) : payload;
961
+ const p = { ...nativePayload, model: mappedModel };
962
+ body = Buffer.from(JSON.stringify(p), 'utf8');
963
+ } else if (outFormat === 'anthropic') {
964
+ const ep = upstreamEndpoint(profile, 'anthropic', mappedModel, ir.stream, req);
965
+ url = ep.url;
966
+ headers = ep.headers;
967
+ let json = { ...payload, model: mappedModel };
968
+ const healed = healAnthropicPayload(json);
969
+ if (healed.changed) json = healed.payload;
970
+ if (profile?.thinkingMode === 'off' && json.thinking) delete json.thinking;
971
+ body = Buffer.from(JSON.stringify(json), 'utf8');
972
+ } else {
973
+ const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
974
+ const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
975
+ url = reqData.url;
976
+ headers = reqData.headers;
977
+ body = JSON.stringify(reqData.upBody);
978
+ }
979
+
980
+ const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
981
+ if (traceId) headers['x-intact-trace'] = traceId;
982
+
983
+ const upRes = await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
984
+ if (!upRes.ok) {
985
+ const errText = await upRes.text().catch(() => '');
986
+ return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
987
+ }
988
+
989
+ const normalize = createUpstreamNormalizer(outFormat);
990
+ const col = createCollector();
991
+ const ctype = upRes.headers.get('content-type') || '';
992
+ if (ctype.includes('application/json')) {
993
+ const parsed = await upRes.json();
994
+ const ev = normalize(parsed);
995
+ col.add(ev);
996
+ if (ev.error) return { malformed: true, error: ev.error };
997
+ } else {
998
+ for await (const parsed of readUpstreamPayloads(upRes)) {
999
+ const ev = normalize(parsed);
1000
+ col.add(ev);
1001
+ if (ev.error) return { malformed: true, error: ev.error };
1002
+ }
1003
+ }
1004
+
1005
+ const collectedText = col.text.join('');
1006
+ return parseEvaluatorVerdict(collectedText);
1007
+ } catch (err) {
1008
+ return { malformed: true, error: err.message || 'native fetch error' };
1009
+ }
1010
+ })();
1011
+
1012
+ const jevPromise = (async () => {
1013
+ if (evidence.stateTooLarge) {
1014
+ return { stateTooLarge: true, chars: evidence.chars, maxChars: evidence.maxChars };
1015
+ }
1016
+ try {
1017
+ return await callJev({
1018
+ url: target.url,
1019
+ model: target.model,
1020
+ apiKey: target.apiKey,
1021
+ state: evidence.state,
1022
+ questions,
1023
+ timeoutMs: policy.timeoutMs,
1024
+ signal: gateAC.signal
1025
+ });
1026
+ } catch (err) {
1027
+ return { ok: false, reason: 'error', message: err.message };
1028
+ }
1029
+ })();
1030
+
1031
+ const [nativeSettled, jevSettled] = await Promise.all([nativePromise, jevPromise]);
1032
+ nativeVerdict = nativeSettled;
1033
+ jevResult = jevSettled;
1034
+ } finally {
1035
+ clearTimeout(gateTimer);
1036
+ }
1037
+
1038
+ const finalVerdict = combineVerdicts({ nativeVerdict, jevResult, thresholds: policy });
1039
+ const latencyMs = Date.now() - startTime;
1040
+
1041
+ logGoalCheck({
1042
+ sessionId,
1043
+ condition: condition.slice(0, 300),
1044
+ nativeVerdict: nativeVerdict?.malformed ? { ok: false, malformed: true, error: nativeVerdict.error } : { ok: nativeVerdict?.ok, reason: nativeVerdict?.reason, impossible: nativeVerdict?.impossible },
1045
+ jevScores: jevResult?.scores || null,
1046
+ verdict: finalVerdict.ok,
1047
+ verdictType: finalVerdict.verdictType,
1048
+ reason: finalVerdict.reason,
1049
+ latencyMs
1050
+ });
1051
+
1052
+ console.log(`[llm-switcher:goal-check] session=${sessionId.slice(0, 16)} native=${nativeVerdict?.ok} jev=${finalVerdict.ok} verdict=${finalVerdict.verdictType} (${latencyMs}ms)`);
1053
+
1054
+ if (gateAC.signal.aborted && req.destroyed) {
1055
+ return log({ status: 499, error: 'client disconnected during goal check' });
1056
+ }
1057
+
1058
+ sendEvaluatorResponse(res, ir.stream, finalVerdict, mappedModel);
1059
+ return log({
1060
+ status: 200,
1061
+ responsePreview: `[goal-check:${finalVerdict.verdictType}] ${finalVerdict.reason.slice(0, 100)}`,
1062
+ tokens: { prompt: 0, completion: 0 }
1063
+ });
1064
+ }
1065
+
890
1066
  // ----------------------------------------------------
891
1067
  // LLM Switcher generic pipeline: client --parse--> IR --emit--> upstream
892
1068
  // Client (input) formats : anthropic | openai-chat | responses (Codex) | vertex
@@ -937,7 +1113,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
937
1113
 
938
1114
  let ir;
939
1115
  try {
940
- ir = parseToIR(clientFormat, payload);
1116
+ ir = parseToIR(clientFormat, payload, { allowOpaqueCompaction: true });
941
1117
  } catch (e) {
942
1118
  sendClientError(res, clientFormat, 400, `Cannot parse ${clientFormat} request: ${e.message}`);
943
1119
  return;
@@ -949,13 +1125,46 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
949
1125
  // Code Assist names streaming in the path (:streamGenerateContent), not in the body.
950
1126
  if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
951
1127
 
1128
+ const evalCheck = isGoalEvaluatorRequest(clientFormat, payload, req.headers);
1129
+ const gcPolicy = goalCheckPolicy(loadConfig());
1130
+ if (evalCheck.isEvaluator && gcPolicy.enabled) {
1131
+ const requestedModel = ir.model || payload.model || '';
1132
+ const mappedModel = mapModel(requestedModel, profile, clientFormat);
1133
+ const reqStartTime = Date.now();
1134
+ const logBase = { clientFormat, outFormat: 'goal-check', profile: profileKey, model: mappedModel, stream: ir.stream, requestPreview: previewOf(ir) };
1135
+ const log = (extra) => logInspection({ ...logBase, duration: Date.now() - reqStartTime, tokens: { prompt: 0, completion: 0 }, ...extra });
1136
+
1137
+ if (!evalCheck.isSupported) {
1138
+ logGoalCheck({
1139
+ sessionId: conversationKey(clientFormat, req, ir) || req.headers['x-claude-code-session-id'] || '',
1140
+ condition: evalCheck.condition || '',
1141
+ nativeVerdict: null,
1142
+ jevScores: null,
1143
+ verdict: false,
1144
+ verdictType: 'Unsupported evaluator',
1145
+ reason: `Unsupported evaluator format: ${evalCheck.reason || 'unrecognized shape'}`,
1146
+ latencyMs: 0
1147
+ });
1148
+ sendEvaluatorResponse(res, ir.stream, {
1149
+ ok: false,
1150
+ reason: `Unsupported evaluator format: ${evalCheck.reason || 'unrecognized shape'}`
1151
+ }, mappedModel);
1152
+ return log({ status: 200, responsePreview: '[goal-check:Unsupported evaluator]' });
1153
+ }
1154
+
1155
+ return await executeGoalCheckGate({
1156
+ req, res, clientFormat, payload, ir, profile, profileKey,
1157
+ policy: gcPolicy, condition: evalCheck.condition, opts, log
1158
+ });
1159
+ }
1160
+
952
1161
  // Compaction rewrites the request itself, not just the intermediate form: when the client and
953
1162
  // the provider speak the same shape this gateway forwards the client's own bytes, so a change
954
1163
  // made only to the parsed form would shorten nothing that leaves the building.
955
1164
  const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
956
1165
  if (shrunk) {
957
1166
  try {
958
- const compactedIR = parseToIR(clientFormat, shrunk.payload);
1167
+ const compactedIR = parseToIR(clientFormat, shrunk.payload, { allowOpaqueCompaction: true });
959
1168
  payload = shrunk.payload;
960
1169
  bodyBuffer = shrunk.bodyBuffer;
961
1170
  ir = compactedIR;
@@ -1008,7 +1217,9 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1008
1217
  const headers = bifrostHeaders(req, profile);
1009
1218
  if (traceId) headers['x-intact-trace'] = traceId;
1010
1219
  try {
1011
- const r = await forwardAnthropicDirect(res, payload, bodyBuffer, bifrostURL(profile, clientFormat, req, opts), headers, mappedModel, ac.signal, profile, true);
1220
+ const nativePayload = clientFormat === 'responses' ? expandGatewayCompactions(payload) : payload;
1221
+ const nativeBytes = nativePayload === payload ? bodyBuffer : Buffer.from(JSON.stringify(nativePayload));
1222
+ const r = await forwardAnthropicDirect(res, nativePayload, nativeBytes, bifrostURL(profile, clientFormat, req, opts), headers, mappedModel, ac.signal, profile, true);
1012
1223
  answered = !r.error && r.status >= 200 && r.status < 300;
1013
1224
  log({ status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
1014
1225
  } catch (err) {
@@ -1038,6 +1249,10 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1038
1249
  return;
1039
1250
  }
1040
1251
 
1252
+ if (ir.opaqueCompaction) {
1253
+ sendClientError(res, clientFormat, 400, 'Cannot convert native or legacy compaction state; resume through its original Responses provider.');
1254
+ return log({ status: 400, error: 'unsupported compaction state' });
1255
+ }
1041
1256
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1042
1257
  const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1043
1258
  if (traceId) headers['x-intact-trace'] = traceId;
@@ -1056,13 +1271,37 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1056
1271
  if (!upstreamRes.ok) {
1057
1272
  const errText = await upstreamRes.text().catch(() => '');
1058
1273
  console.error(`[${profileKey}] Error HTTP ${upstreamRes.status}:`, errText.slice(0, 500));
1059
- sendClientError(res, clientFormat, upstreamRes.status, extractUpstreamMessage(errText) || `Upstream HTTP ${upstreamRes.status}`, pickRetryHeaders(upstreamRes));
1274
+ let userFriendlyMsg = extractUpstreamMessage(errText) || `Upstream HTTP ${upstreamRes.status}`;
1275
+ if (upstreamRes.status === 404 && (errText.toLowerCase().includes('model') || userFriendlyMsg.toLowerCase().includes('model'))) {
1276
+ userFriendlyMsg = `Model '${mappedModel}' is not available or has been deprecated on profile '${profileKey}' (HTTP 404: ${userFriendlyMsg}). Open 'switch ui' to reconfigure your model.`;
1277
+ } else if (upstreamRes.status === 401 || upstreamRes.status === 403) {
1278
+ userFriendlyMsg = `API key for profile '${profileKey}' was rejected (HTTP ${upstreamRes.status}: ${userFriendlyMsg}). Check credentials in 'switch ui'.`;
1279
+ }
1280
+ sendClientError(res, clientFormat, upstreamRes.status, userFriendlyMsg, pickRetryHeaders(upstreamRes));
1060
1281
  return log({ status: upstreamRes.status, error: errText.slice(0, 300) });
1061
1282
  }
1062
1283
 
1063
1284
  const normalize = createUpstreamNormalizer(outFormat);
1064
1285
  const col = createCollector();
1065
1286
 
1287
+ if (ir.compaction) {
1288
+ try {
1289
+ await collectCompaction(upstreamRes, outFormat, ir.stream, normalize, col);
1290
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1291
+ const response = compactionResponse(col, requestedModel || mappedModel, text => splitThinkTags(text).text);
1292
+ if (ir.stream) {
1293
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
1294
+ emitCompaction(response, (event, data) => sendSSE(res, event, data)); res.end();
1295
+ } else sendJson(res, 200, response);
1296
+ answered = true;
1297
+ return log({ status: 200, responsePreview: '(compaction)', tokens: { prompt: col.prompt, completion: col.completion() } });
1298
+ } catch (err) {
1299
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1300
+ sendClientError(res, clientFormat, 502, err.message);
1301
+ return log({ status: 502, error: err.message });
1302
+ }
1303
+ }
1304
+
1066
1305
  // ---- non-stream ----
1067
1306
  if (!ir.stream) {
1068
1307
  let json;
@@ -1085,7 +1324,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1085
1324
  const out = clientMessage(clientFormat, {
1086
1325
  model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
1087
1326
  finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
1088
- reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir)
1327
+ reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir, outFormat)
1089
1328
  });
1090
1329
  sendJson(res, 200, out);
1091
1330
  answered = true;
@@ -1102,7 +1341,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1102
1341
  'Connection': 'keep-alive',
1103
1342
  'X-Accel-Buffering': 'no'
1104
1343
  });
1105
- const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir) });
1344
+ const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
1106
1345
  renderer.start();
1107
1346
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
1108
1347
  const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
@@ -1735,6 +1974,92 @@ async function routeApi(req, res, method, pathname) {
1735
1974
  return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
1736
1975
  }
1737
1976
 
1977
+ // Goal Check
1978
+ if (method === 'GET' && pathname === '/api/goal-check') {
1979
+ const cfg = loadConfig() || {};
1980
+ const policy = goalCheckPolicy(cfg);
1981
+ const target = resolveJevTarget(policy, cfg.profiles || {});
1982
+ const configured = Boolean(target.url && (target.apiKey || policy.sourceProfile));
1983
+ return sendJson(res, 200, {
1984
+ goalCheck: redactGoalCheck(policy, MASKED_KEY),
1985
+ status: {
1986
+ enabled: policy.enabled,
1987
+ configured,
1988
+ model: policy.model,
1989
+ endpoint: target.url || '(none)'
1990
+ }
1991
+ });
1992
+ }
1993
+ if (method === 'POST' && pathname === '/api/goal-check') {
1994
+ let patch;
1995
+ try {
1996
+ patch = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
1997
+ } catch (err) {
1998
+ return sendJson(res, err.status || 400, { error: err.message });
1999
+ }
2000
+ const currentCfg = loadConfig() || {};
2001
+ const currentPolicy = goalCheckPolicy(currentCfg);
2002
+ const invalid = validateGoalCheckPatch(patch, currentPolicy);
2003
+ if (invalid) return sendJson(res, 400, { error: invalid });
2004
+
2005
+ const currentStoredKey = (currentCfg[GOAL_CHECK_KEY]?.apiKey) || '';
2006
+ if (patch.apiKey === MASKED_KEY) {
2007
+ patch.apiKey = currentStoredKey;
2008
+ } else if (patch.apiKey === undefined && Object.hasOwn(currentCfg, GOAL_CHECK_KEY)) {
2009
+ patch.apiKey = currentStoredKey;
2010
+ }
2011
+
2012
+ currentCfg[GOAL_CHECK_KEY] = { ...(currentCfg[GOAL_CHECK_KEY] || {}), ...patch };
2013
+ try {
2014
+ saveConfig(currentCfg);
2015
+ } catch (err) {
2016
+ return sendJson(res, 500, { error: err.message });
2017
+ }
2018
+
2019
+ const updatedPolicy = goalCheckPolicy(loadConfig());
2020
+ const target = resolveJevTarget(updatedPolicy, (loadConfig() || {}).profiles || {});
2021
+ const configured = Boolean(target.url && (target.apiKey || updatedPolicy.sourceProfile));
2022
+ return sendJson(res, 200, {
2023
+ goalCheck: redactGoalCheck(updatedPolicy, MASKED_KEY),
2024
+ status: {
2025
+ enabled: updatedPolicy.enabled,
2026
+ configured,
2027
+ model: updatedPolicy.model,
2028
+ endpoint: target.url || '(none)'
2029
+ }
2030
+ });
2031
+ }
2032
+ if (method === 'POST' && pathname === '/api/goal-check/test') {
2033
+ let body;
2034
+ try {
2035
+ body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
2036
+ } catch (err) {
2037
+ return sendJson(res, err.status || 400, { error: err.message });
2038
+ }
2039
+ const cfg = loadConfig() || {};
2040
+ const currentGC = cfg[GOAL_CHECK_KEY] || {};
2041
+ const currentStoredKey = currentGC.apiKey || '';
2042
+ const apiKey = body.apiKey === MASKED_KEY ? currentStoredKey : (body.apiKey ?? currentStoredKey);
2043
+ const baseURL = body.baseURL !== undefined ? body.baseURL : (currentGC.baseURL || '');
2044
+ const decisionEndpoint = body.decisionEndpoint !== undefined ? body.decisionEndpoint : (currentGC.decisionEndpoint || '');
2045
+ const model = body.model !== undefined ? body.model : (currentGC.model || 'typesafe/jev-latest');
2046
+ const sourceProfile = body.sourceProfile !== undefined ? body.sourceProfile : (currentGC.sourceProfile || '');
2047
+
2048
+ const testRes = await testJevConnection({
2049
+ baseURL,
2050
+ decisionEndpoint,
2051
+ apiKey,
2052
+ model,
2053
+ sourceProfile,
2054
+ profiles: cfg.profiles || {},
2055
+ timeoutMs: body.timeoutMs || 8000
2056
+ });
2057
+ return sendJson(res, 200, testRes);
2058
+ }
2059
+ if (method === 'GET' && pathname === '/api/goal-check/logs') {
2060
+ return sendJson(res, 200, { logs: goalCheckLogs.slice().reverse() });
2061
+ }
2062
+
1738
2063
  if (method !== 'POST') {
1739
2064
  req.resume();
1740
2065
  return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
@@ -2019,7 +2344,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
2019
2344
  // previous_response_id; the server keeps the rest. The gateway keeps it per socket, bounded.
2020
2345
  const WS_HISTORY_MAX = 8;
2021
2346
  // Only these output items are valid input for the next turn; reasoning items are not replayed.
2022
- const WS_REPLAY_TYPES = new Set(['message', 'function_call', 'custom_tool_call', 'local_shell_call']);
2347
+ const WS_REPLAY_TYPES = new Set(['message', 'function_call', 'custom_tool_call', 'local_shell_call', 'compaction']);
2023
2348
 
2024
2349
  function wsInputItems(input) {
2025
2350
  if (typeof input === 'string') return input ? [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: input }] }] : [];
@@ -2169,7 +2494,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2169
2494
 
2170
2495
  const bifrostModel = mapModel(payload.model || '', profile, clientFormat);
2171
2496
  if (await crossesBifrost(req, profile, bifrostModel)) {
2172
- return wsBifrostTurn({ socket, payload, req, ac, history, input, profile, profileKey, mappedModel: bifrostModel });
2497
+ return wsBifrostTurn({ socket, payload: expandGatewayCompactions(payload), req, ac, history, input, profile, profileKey, mappedModel: bifrostModel });
2173
2498
  }
2174
2499
 
2175
2500
  let ir;
@@ -2224,6 +2549,21 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2224
2549
  const normalize = createUpstreamNormalizer(outFormat);
2225
2550
  const col = createCollector();
2226
2551
 
2552
+ if (ir.compaction) {
2553
+ try {
2554
+ await collectCompaction(upstreamRes, outFormat, true, normalize, col);
2555
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2556
+ const response = compactionResponse(col, requestedModel || mappedModel, text => splitThinkTags(text).text);
2557
+ emitCompaction(response, (event, data) => { tapWsEvent(halfTap, event, data); if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(data))); });
2558
+ rememberWsTurn(history, response, []); answered = true;
2559
+ return log({ status: 200, responsePreview: '(compaction)', tokens: { prompt: col.prompt, completion: col.completion() } });
2560
+ } catch (err) {
2561
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2562
+ sendWsFailed(socket, mappedModel, err.message, 502, halfTap);
2563
+ return log({ status: 502, error: err.message });
2564
+ }
2565
+ }
2566
+
2227
2567
  let completedResponse = null;
2228
2568
  const renderer = createResponsesStream((e, d) => {
2229
2569
  tapWsEvent(halfTap, e, d);
@@ -2231,7 +2571,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2231
2571
  if (socket.writable) {
2232
2572
  socket.write(encodeWsFrame(JSON.stringify(d)));
2233
2573
  }
2234
- }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir) });
2574
+ }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
2235
2575
 
2236
2576
  renderer.start();
2237
2577
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
package/shim.mjs CHANGED
@@ -43,6 +43,7 @@ const GATEWAY_CHECK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'v
43
43
  // The launch check covers the start only. A routed agy then runs behind this relay, which proves
44
44
  // the gateway on every connection, so a port freed later never receives the token.
45
45
  const AGY_RELAY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'agy-relay.mjs');
46
+ const PREFLIGHT_CHECK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'preflight-check.mjs');
46
47
 
47
48
  // CLIs to wrap. `claude` is the most important case (--resume); codex and agy have their own files.
48
49
  export const SHIMMED = ['claude', 'codex', 'agy'];
@@ -81,6 +82,7 @@ fi`;
81
82
  # "${name}", from any shell — including one that never sourced anything (claude --resume).
82
83
  SWITCHER_DIR="${STATE_DIR}"
83
84
  STATE_HELPER="${STATE_HELPER}"
85
+ PREFLIGHT_CHECK="${PREFLIGHT_CHECK}"
84
86
  SHIM_DIR="\${BASH_SOURCE%/*}"
85
87
 
86
88
  # Captured BEFORE the tool's env file is sourced: the CA decision must see the value this shell
@@ -126,7 +128,12 @@ fi
126
128
  # here. Only while THIS tool is routed: an empty route file means the tool is off, so the tool
127
129
  # reaches its official endpoint and there is nothing to warn about.
128
130
  if [ "$TOOL_ACTIVE" = "1" ] && [ -s "$SWITCHER_DIR/${routeFile}" ]; then
129
- printf '[llm-switcher] %s\\n' "$(cat "$SWITCHER_DIR/${routeFile}")" >&2
131
+ PREFLIGHT_OUT="$(node "$PREFLIGHT_CHECK" "${name}" "$SWITCHER_DIR" 2>/dev/null)" || PREFLIGHT_OUT=""
132
+ if [ -n "$PREFLIGHT_OUT" ]; then
133
+ printf '%s\\n' "$PREFLIGHT_OUT" >&2
134
+ else
135
+ printf '[llm-switcher] %s\\n' "$(cat "$SWITCHER_DIR/${routeFile}")" >&2
136
+ fi
130
137
  fi
131
138
 
132
139
  # R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
@@ -248,6 +255,7 @@ set "NODE_EXTRA_CA_CERTS=%CA_BUNDLE%"
248
255
  REM Auto-generated by LLM Switcher - DO NOT EDIT.
249
256
  setlocal
250
257
  set "SWITCHER_DIR=${STATE_DIR}"
258
+ set "PREFLIGHT_CHECK=${PREFLIGHT_CHECK}"
251
259
  ${!needsCaBlock(name) ? '' : `set "STATE_HELPER=${STATE_HELPER}"
252
260
 
253
261
  REM Captured BEFORE the tool's env file runs: the CA decision must see the value this shell
@@ -280,7 +288,10 @@ if "%TOOL_ACTIVE%"=="1" call :CHECK_GATEWAY
280
288
  REM Nothing on screen tells the person that the switcher took this tool's traffic, and both TUIs
281
289
  REM can claim the whole screen, so a printed line would be hidden. A toast is not. It is detached:
282
290
  REM the tool never waits for it. Only while THIS tool is routed (%TOOL_ACTIVE%).
283
- if "%TOOL_ACTIVE%"=="1" if exist "%SWITCHER_DIR%\\${routeFile}" start "" /b powershell -NoProfile -ExecutionPolicy Bypass -File "${ROUTE_NOTIFIER}" "%SWITCHER_DIR%\\${routeFile}" >nul 2>&1
291
+ if "%TOOL_ACTIVE%"=="1" if exist "%SWITCHER_DIR%\\${routeFile}" (
292
+ for /f "delims=" %%M in ('node "%PREFLIGHT_CHECK%" "${name}" "%SWITCHER_DIR%" 2^>nul') do echo %%M 1>&2
293
+ start "" /b powershell -NoProfile -ExecutionPolicy Bypass -File "${ROUTE_NOTIFIER}" "%SWITCHER_DIR%\\${routeFile}" >nul 2>&1
294
+ )
284
295
 
285
296
  REM R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
286
297
  REM proxy, base URL, model or LLM_SWITCHER_* setting is theirs and is kept.
package/state.mjs CHANGED
@@ -1680,6 +1680,11 @@ export function redactConfig(cfg) {
1680
1680
  lab.hasApiKey = Boolean(lab.apiKey);
1681
1681
  lab.apiKey = lab.apiKey ? MASKED_KEY : '';
1682
1682
  }
1683
+ const gc = clone.goalCheck;
1684
+ if (gc && typeof gc === 'object' && !Array.isArray(gc)) {
1685
+ gc.hasApiKey = Boolean(gc.apiKey);
1686
+ gc.apiKey = gc.apiKey ? MASKED_KEY : '';
1687
+ }
1683
1688
  return clone;
1684
1689
  }
1685
1690
 
@@ -12,6 +12,7 @@ import { buildCerts } from '../blindfold/certs.mjs';
12
12
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
13
13
  process.env.HOME = path.join(root, 'home');
14
14
  process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
15
+ process.env.LLM_SWITCHER_BLINDFOLD_CERTS = path.join(root, 'certs');
15
16
  delete process.env.CODEX_HOME;
16
17
  fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
17
18
  const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, computeLaunchState, STATE_DIR, INTERCEPT_HOSTS, usableCACertificate } = await import('../state.mjs');