llm-switcher 1.6.6 → 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
@@ -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
- 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));
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
- 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