llm-switcher 1.6.6 → 1.6.8
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/CHANGELOG.md +12 -0
- package/docs/goal-check.md +75 -0
- package/goalcheck-call.mjs +208 -0
- package/goalcheck.mjs +498 -0
- package/package.json +1 -1
- package/preflight-check.mjs +188 -0
- package/proxy.mjs +341 -2
- package/shim.mjs +13 -2
- package/state.mjs +5 -0
- package/tests/goalcheck.e2e.test.mjs +316 -0
- package/tests/goalcheck.test.mjs +312 -0
- package/tests/preflight.test.mjs +119 -0
- package/tests/ui.test.mjs +35 -0
- package/ui.html +1629 -31
package/proxy.mjs
CHANGED
|
@@ -35,10 +35,24 @@ import { checkForUpdate } from './version.mjs';
|
|
|
35
35
|
import { applyUpdate } from './update.mjs';
|
|
36
36
|
import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
|
|
37
37
|
import {
|
|
38
|
-
idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
|
|
38
|
+
idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY, conversationKey
|
|
39
39
|
} from './idlecompact.mjs';
|
|
40
40
|
import { askSummary } from './idlecall.mjs';
|
|
41
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
|
+
}
|
|
42
56
|
|
|
43
57
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
58
|
const uiHtmlPath = path.join(__dirname, 'ui.html');
|
|
@@ -849,6 +863,206 @@ async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, sig
|
|
|
849
863
|
// once, so a slow or absent intact cannot reach the answer the client is waiting for.
|
|
850
864
|
const contractLab = createContractLab({ settings: () => contractLabSettings(loadConfig()) });
|
|
851
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
|
+
|
|
852
1066
|
// ----------------------------------------------------
|
|
853
1067
|
// LLM Switcher generic pipeline: client --parse--> IR --emit--> upstream
|
|
854
1068
|
// Client (input) formats : anthropic | openai-chat | responses (Codex) | vertex
|
|
@@ -911,6 +1125,39 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
911
1125
|
// Code Assist names streaming in the path (:streamGenerateContent), not in the body.
|
|
912
1126
|
if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
|
|
913
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
|
+
|
|
914
1161
|
// Compaction rewrites the request itself, not just the intermediate form: when the client and
|
|
915
1162
|
// the provider speak the same shape this gateway forwards the client's own bytes, so a change
|
|
916
1163
|
// made only to the parsed form would shorten nothing that leaves the building.
|
|
@@ -1024,7 +1271,13 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1024
1271
|
if (!upstreamRes.ok) {
|
|
1025
1272
|
const errText = await upstreamRes.text().catch(() => '');
|
|
1026
1273
|
console.error(`[${profileKey}] Error HTTP ${upstreamRes.status}:`, errText.slice(0, 500));
|
|
1027
|
-
|
|
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));
|
|
1028
1281
|
return log({ status: upstreamRes.status, error: errText.slice(0, 300) });
|
|
1029
1282
|
}
|
|
1030
1283
|
|
|
@@ -1721,6 +1974,92 @@ async function routeApi(req, res, method, pathname) {
|
|
|
1721
1974
|
return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
|
|
1722
1975
|
}
|
|
1723
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
|
+
|
|
1724
2063
|
if (method !== 'POST') {
|
|
1725
2064
|
req.resume();
|
|
1726
2065
|
return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
|
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
|
-
|
|
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}"
|
|
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
|
|