llm-switcher 1.2.11 → 1.3.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/CHANGELOG.md +31 -0
- package/README.md +124 -20
- package/README.vi.md +123 -20
- package/config.example.json +13 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/formats.mjs +128 -12
- package/hook-status.mjs +5 -4
- package/mcp.mjs +6 -5
- package/package.json +1 -1
- package/plugin.mjs +80 -9
- package/proxy.mjs +211 -20
- package/service.mjs +12 -6
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +50 -13
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +115 -24
- package/tests/agy.e2e.test.mjs +529 -0
- package/tests/agy.unit.test.mjs +190 -0
- package/tests/dashboard.e2e.test.mjs +18 -17
- package/tests/formats.test.mjs +67 -1
- package/tests/gateway.e2e.test.mjs +20 -2
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/service.test.mjs +27 -1
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/tests/update.e2e.test.mjs +197 -0
- package/tests/update.test.mjs +145 -0
- package/ui.html +121 -8
- package/update.mjs +98 -0
- package/verify-gateway.mjs +35 -0
package/proxy.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import zlib from 'node:zlib';
|
|
5
6
|
import crypto from 'node:crypto';
|
|
6
7
|
import { promisify } from 'node:util';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
9
11
|
import {
|
|
@@ -11,7 +13,8 @@ import {
|
|
|
11
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
|
|
12
14
|
toGeminiSchema, isAntigravityModel,
|
|
13
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
14
|
-
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage
|
|
16
|
+
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
17
|
+
createCodeAssistStream, buildCodeAssistMessage
|
|
15
18
|
} from './formats.mjs';
|
|
16
19
|
import {
|
|
17
20
|
TOOLS, configPath, loadConfig, getConfigLoadError, saveConfig, resolvePort, hasProfile, isValidProfileKey,
|
|
@@ -21,10 +24,11 @@ import {
|
|
|
21
24
|
modelForSlot, primaryModel, codexPublicModel, isSafeModelName, parsePort, CODEX_MODEL_SLOTS,
|
|
22
25
|
ensureAdminToken, identityProof, reconcileBlindfold, checkBlindfoldTarget,
|
|
23
26
|
codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
|
|
24
|
-
contractLabSettings, STATE_DIR
|
|
27
|
+
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
25
28
|
} from './state.mjs';
|
|
26
29
|
import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
|
|
27
30
|
import { checkForUpdate } from './version.mjs';
|
|
31
|
+
import { applyUpdate } from './update.mjs';
|
|
28
32
|
import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
|
|
29
33
|
|
|
30
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -46,6 +50,7 @@ if (missingNoProxy.length > 0) {
|
|
|
46
50
|
// Fixed port for the process lifetime: changing "port" in config.json at runtime has no effect
|
|
47
51
|
// env.cmd / flags would point at a port the server is not listening on.
|
|
48
52
|
const PORT = resolvePort();
|
|
53
|
+
let updateInProgress = false;
|
|
49
54
|
|
|
50
55
|
// In-memory Request / Response Inspector Ring Buffer (up to 40 most recent requests)
|
|
51
56
|
const requestLogs = [];
|
|
@@ -201,8 +206,8 @@ async function readJsonBody(req, limit = MAX_API_BODY_SIZE) {
|
|
|
201
206
|
// ----------------------------------------------------
|
|
202
207
|
// Profile & model resolution
|
|
203
208
|
// ----------------------------------------------------
|
|
204
|
-
// R5:
|
|
205
|
-
const CLIENT_FORMAT_TOOL = { anthropic: 'claude', responses: 'codex' };
|
|
209
|
+
// R5: one input protocol per tool. codeassist is the Antigravity CLI (agy).
|
|
210
|
+
const CLIENT_FORMAT_TOOL = { anthropic: 'claude', responses: 'codex', codeassist: 'agy' };
|
|
206
211
|
|
|
207
212
|
function getActiveProfile(clientFormat, req) {
|
|
208
213
|
const cfg = loadConfig();
|
|
@@ -246,6 +251,15 @@ function getFirstActiveProfile(preferred, req) {
|
|
|
246
251
|
// clientFormat is the protocol the request arrived in. An `auto` profile serves every protocol,
|
|
247
252
|
// so the profile's inFormat cannot tell a Codex request from a Claude one.
|
|
248
253
|
function mapModel(requestedModel, profile, clientFormat) {
|
|
254
|
+
if (clientFormat === 'codeassist') {
|
|
255
|
+
// agy picks a model per call (gemini-3.8-flash-high). The main slot names the upstream model;
|
|
256
|
+
// '*' in it stands for that pick, so antigravity/* keeps agy's choice on an intact pool.
|
|
257
|
+
const slot = modelForSlot(profile, 'main');
|
|
258
|
+
if (!slot) return requestedModel || '';
|
|
259
|
+
if (!slot.includes('*')) return slot;
|
|
260
|
+
// A function replacer: a model name with $& or $1 in it must not expand.
|
|
261
|
+
return requestedModel ? slot.replace('*', () => requestedModel) : '';
|
|
262
|
+
}
|
|
249
263
|
if (!requestedModel) return primaryModel(profile);
|
|
250
264
|
const clean = requestedModel.replace(/\[1m\]/gi, '').trim();
|
|
251
265
|
// If the client specified a model with a provider prefix (e.g. ag/..., gh/..., cf/...), keep it as-is
|
|
@@ -344,7 +358,7 @@ function sendClientError(res, clientFormat, status, message, headers = {}) {
|
|
|
344
358
|
let body;
|
|
345
359
|
if (clientFormat === 'anthropic') {
|
|
346
360
|
body = { type: 'error', error: { type: anthropicErrorType(status), message } };
|
|
347
|
-
} else if (clientFormat === 'vertex') {
|
|
361
|
+
} else if (clientFormat === 'vertex' || clientFormat === 'codeassist') {
|
|
348
362
|
body = { error: { code: status, message, status: vertexStatus(status) } };
|
|
349
363
|
} else {
|
|
350
364
|
body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: String(status) } };
|
|
@@ -475,6 +489,7 @@ function clientRenderer(clientFormat, res, model, opts = {}) {
|
|
|
475
489
|
if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model);
|
|
476
490
|
if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
477
491
|
if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model);
|
|
492
|
+
if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model);
|
|
478
493
|
return createChatStream((e, d) => sendSSE(res, null, d), model);
|
|
479
494
|
}
|
|
480
495
|
|
|
@@ -482,6 +497,7 @@ function clientMessage(clientFormat, args) {
|
|
|
482
497
|
if (clientFormat === 'anthropic') return buildAnthropicMessage(args);
|
|
483
498
|
if (clientFormat === 'responses') return buildResponsesMessage(args);
|
|
484
499
|
if (clientFormat === 'vertex') return buildVertexMessage(args);
|
|
500
|
+
if (clientFormat === 'codeassist') return buildCodeAssistMessage(args);
|
|
485
501
|
return buildChatMessage(args);
|
|
486
502
|
}
|
|
487
503
|
|
|
@@ -503,6 +519,11 @@ const HOP_BY_HOP = new Set(['content-length', 'content-encoding', 'transfer-enco
|
|
|
503
519
|
// Bifrost: intact names, per model, the User-Agent prefix of the provider's own client (bifrost_ua).
|
|
504
520
|
// When the caller is that client, the request crosses as it is: only the key changes.
|
|
505
521
|
const bifrostUACache = new Map();
|
|
522
|
+
// A failed lookup holds the convert route only this long, so a short intact outage does not pin it.
|
|
523
|
+
const BIFROST_RETRY_MS = (() => {
|
|
524
|
+
const v = Number(process.env.LLM_SWITCHER_BIFROST_RETRY_MS);
|
|
525
|
+
return Number.isFinite(v) && v >= 0 ? v : 30000;
|
|
526
|
+
})();
|
|
506
527
|
async function bifrostUAFor(profile, model) {
|
|
507
528
|
const base = String(profile.baseURL || '').replace(/\/+$/, '');
|
|
508
529
|
if (!base || !model) return '';
|
|
@@ -510,16 +531,20 @@ async function bifrostUAFor(profile, model) {
|
|
|
510
531
|
const hit = bifrostUACache.get(key);
|
|
511
532
|
if (hit && hit.until > Date.now()) return hit.ua;
|
|
512
533
|
let ua = '';
|
|
513
|
-
let
|
|
534
|
+
let failure = '';
|
|
514
535
|
try {
|
|
515
536
|
const r = await fetch(`${base}/models/${model.split('/').map(encodeURIComponent).join('/')}`, {
|
|
516
537
|
headers: { authorization: `Bearer ${profile.apiKey || ''}` }, signal: AbortSignal.timeout(3000)
|
|
517
538
|
});
|
|
539
|
+
// Only a 2xx is an answer. A 404 can be an intact older than 0.1.14 or a wrong baseURL.
|
|
518
540
|
if (r.ok) ua = String((await r.json())?.bifrost_ua || '');
|
|
519
|
-
|
|
520
|
-
|
|
541
|
+
else failure = `HTTP ${r.status}`;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
failure = err.message;
|
|
521
544
|
}
|
|
522
|
-
|
|
545
|
+
// One line per failure streak, not one per retry.
|
|
546
|
+
if (failure && !hit?.failed) console.warn(`[llm-switcher] Bifrost lookup of "${model}" at ${base} failed (${failure}); the convert route serves it, retry in ${BIFROST_RETRY_MS} ms`);
|
|
547
|
+
bifrostUACache.set(key, { ua, failed: Boolean(failure), until: Date.now() + (failure ? BIFROST_RETRY_MS : 10 * 60 * 1000) });
|
|
523
548
|
return ua;
|
|
524
549
|
}
|
|
525
550
|
|
|
@@ -542,6 +567,15 @@ function bifrostHeaders(req, profile) {
|
|
|
542
567
|
return headers;
|
|
543
568
|
}
|
|
544
569
|
|
|
570
|
+
// The client formats a provider's own CLI speaks, and where Bifrost sends each one on intact.
|
|
571
|
+
const BIFROST_FORMATS = new Set(['anthropic', 'responses', 'codeassist']);
|
|
572
|
+
function bifrostURL(profile, clientFormat, req, opts = {}) {
|
|
573
|
+
const base = String(profile.baseURL || '').replace(/\/+$/, '');
|
|
574
|
+
if (clientFormat === 'responses') return withClientQuery(`${base}/responses`, req);
|
|
575
|
+
if (clientFormat === 'codeassist') return withClientQuery(`${base}/v1internal:${opts.codeAssistAction}`, req);
|
|
576
|
+
return withClientQuery(upstreamEndpoint(profile, 'anthropic', '', false, req).url, req);
|
|
577
|
+
}
|
|
578
|
+
|
|
545
579
|
function withClientQuery(url, req) {
|
|
546
580
|
const q = String(req.url || '').indexOf('?');
|
|
547
581
|
return q < 0 ? url : url + (url.includes('?') ? '&' : '?') + req.url.slice(q + 1);
|
|
@@ -602,7 +636,9 @@ async function forwardAnthropicDirect(res, payload, bodyBuffer, url, headers, ma
|
|
|
602
636
|
streamError = `stream interrupted: ${streamErr.cause?.message || streamErr.message}`;
|
|
603
637
|
}
|
|
604
638
|
} finally {
|
|
605
|
-
|
|
639
|
+
// A cut stream must not reach the client as a whole answer.
|
|
640
|
+
if (streamError && !signal.aborted) res.destroy();
|
|
641
|
+
else res.end();
|
|
606
642
|
}
|
|
607
643
|
const error = streamError || (upstreamRes.ok ? null : errorPreview.slice(0, 300));
|
|
608
644
|
return { status, error, healed: healed.notes, tokens: usage.tokens() };
|
|
@@ -619,13 +655,16 @@ function createUsageTap(limit = 4 * 1024 * 1024) {
|
|
|
619
655
|
if (!u || typeof u !== 'object') return;
|
|
620
656
|
if (typeof u.input_tokens === 'number') tokens.prompt = u.input_tokens + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
621
657
|
if (typeof u.output_tokens === 'number') tokens.completion = u.output_tokens;
|
|
658
|
+
// Gemini (Code Assist): thought tokens are billed as output.
|
|
659
|
+
if (typeof u.promptTokenCount === 'number') tokens.prompt = u.promptTokenCount;
|
|
660
|
+
if (typeof u.candidatesTokenCount === 'number') tokens.completion = u.candidatesTokenCount + (u.thoughtsTokenCount || 0);
|
|
622
661
|
};
|
|
623
662
|
const line = (l) => {
|
|
624
663
|
const t = l.startsWith('data:') ? l.slice(5).trim() : '';
|
|
625
|
-
if (!t.includes('"usage"')) return;
|
|
664
|
+
if (!t.includes('"usage"') && !t.includes('"usageMetadata"')) return;
|
|
626
665
|
try {
|
|
627
666
|
const d = JSON.parse(t);
|
|
628
|
-
take(d.message?.usage || d.usage);
|
|
667
|
+
take(d.message?.usage || d.usage || d.response?.usage || d.response?.usageMetadata || d.usageMetadata);
|
|
629
668
|
} catch {}
|
|
630
669
|
};
|
|
631
670
|
return {
|
|
@@ -640,7 +679,10 @@ function createUsageTap(limit = 4 * 1024 * 1024) {
|
|
|
640
679
|
tokens() {
|
|
641
680
|
text += decoder.decode();
|
|
642
681
|
if (text.trim().startsWith('{')) {
|
|
643
|
-
try {
|
|
682
|
+
try {
|
|
683
|
+
const j = JSON.parse(text);
|
|
684
|
+
take(j.usage || j.response?.usageMetadata || j.usageMetadata);
|
|
685
|
+
} catch {}
|
|
644
686
|
} else if (text) line(text);
|
|
645
687
|
return tokens;
|
|
646
688
|
}
|
|
@@ -770,12 +812,18 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
770
812
|
ir.stream = Boolean(opts.vertexStream);
|
|
771
813
|
if (!ir.model && opts.vertexModel) ir.model = opts.vertexModel;
|
|
772
814
|
}
|
|
815
|
+
// Code Assist names streaming in the path (:streamGenerateContent), not in the body.
|
|
816
|
+
if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
|
|
773
817
|
|
|
774
818
|
const reqStartTime = Date.now();
|
|
775
819
|
const requestPreview = previewOf(ir);
|
|
776
820
|
const requestedModel = ir.model || payload.model || '';
|
|
777
821
|
const mappedModel = mapModel(requestedModel, profile, clientFormat);
|
|
778
|
-
|
|
822
|
+
if (clientFormat === 'codeassist' && !mappedModel) {
|
|
823
|
+
sendClientError(res, clientFormat, 400, 'The request names no model, and the main slot of the agy profile needs one (*).');
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const bifrost = BIFROST_FORMATS.has(clientFormat) && await crossesBifrost(req, profile, mappedModel);
|
|
779
827
|
const outFormat = resolveOutFormat(profile, mappedModel);
|
|
780
828
|
const route = bifrost ? 'bifrost' : outFormat;
|
|
781
829
|
console.log(`[llm-switcher] ${clientFormat} -> ${route} "${requestedModel}" -> "${mappedModel}" [${profile.name || profileKey}]`);
|
|
@@ -785,7 +833,9 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
785
833
|
|
|
786
834
|
// Contract lab: a sampled request carries a trace id to intact, and the bytes this gateway
|
|
787
835
|
// writes back are copied for the upload that follows the answer.
|
|
788
|
-
|
|
836
|
+
// The contract lab compares converters of the Claude Code and Codex shapes; it has no reducer for
|
|
837
|
+
// the Code Assist shape, so an agy exchange is never sampled.
|
|
838
|
+
const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
|
|
789
839
|
const halfTap = traceId ? createHalfTap() : null;
|
|
790
840
|
if (halfTap) tapClientWrites(res, halfTap);
|
|
791
841
|
|
|
@@ -804,11 +854,11 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
804
854
|
// Bifrost: the provider's own client reaching its own account through intact. Bytes and headers
|
|
805
855
|
// go as sent, only the key changes: no healer, no thinkingMode, no conversion.
|
|
806
856
|
if (bifrost) {
|
|
807
|
-
|
|
857
|
+
// bifrostHeaders drops the client's own Authorization: agy's Google token never reaches intact.
|
|
808
858
|
const headers = bifrostHeaders(req, profile);
|
|
809
859
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
810
860
|
try {
|
|
811
|
-
const r = await forwardAnthropicDirect(res, payload, bodyBuffer,
|
|
861
|
+
const r = await forwardAnthropicDirect(res, payload, bodyBuffer, bifrostURL(profile, clientFormat, req, opts), headers, mappedModel, ac.signal, profile, true);
|
|
812
862
|
answered = !r.error && r.status >= 200 && r.status < 300;
|
|
813
863
|
log({ status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
|
|
814
864
|
} catch (err) {
|
|
@@ -938,7 +988,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
938
988
|
toolResponse: halfTap?.text() || '',
|
|
939
989
|
toolVersion: toolVersionFromUA(req.headers['user-agent']),
|
|
940
990
|
inFormat: clientFormat,
|
|
941
|
-
outFormat: bifrost ?
|
|
991
|
+
outFormat: bifrost ? clientFormat : outFormat, // Bifrost converts nothing
|
|
942
992
|
openerKey: profile.apiKey || ''
|
|
943
993
|
});
|
|
944
994
|
}
|
|
@@ -1055,7 +1105,7 @@ function validateProfileInput(p) {
|
|
|
1055
1105
|
if (typeof p[k] === 'string' && CONTROL_CHARS.test(p[k])) return `${k} must not contain control characters`;
|
|
1056
1106
|
}
|
|
1057
1107
|
// R6 / R7(c): a profile declares exactly one tool. An old-format field must never be written back.
|
|
1058
|
-
if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude"
|
|
1108
|
+
if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude", "codex" or "agy"';
|
|
1059
1109
|
if (p.blindfold !== undefined) return 'blindfold per-profile setting is deprecated';
|
|
1060
1110
|
if (p.blindfoldPort !== undefined) return 'blindfoldPort per-profile setting is deprecated';
|
|
1061
1111
|
if (p.blindfoldHost !== undefined) return 'blindfoldHost per-profile setting is deprecated';
|
|
@@ -1312,6 +1362,9 @@ async function route(req, res) {
|
|
|
1312
1362
|
return handleCountTokens(req, res, buf);
|
|
1313
1363
|
}
|
|
1314
1364
|
|
|
1365
|
+
// The Antigravity CLI (agy): CLOUD_CODE_URL points every Code Assist call at this gateway.
|
|
1366
|
+
if (pathname.startsWith('/v1internal:')) return routeCodeAssist(req, res, method, pathname);
|
|
1367
|
+
|
|
1315
1368
|
let clientFormat = null;
|
|
1316
1369
|
|
|
1317
1370
|
// Codex CLI sends GET /v1/responses (and /v1/responses/{id}) to fetch model
|
|
@@ -1352,6 +1405,93 @@ async function route(req, res) {
|
|
|
1352
1405
|
return sendJson(res, 404, { error: { message: `Not found: ${method} ${pathname}` } });
|
|
1353
1406
|
}
|
|
1354
1407
|
|
|
1408
|
+
// ----------------------------------------------------
|
|
1409
|
+
// Antigravity CLI (agy)
|
|
1410
|
+
// ----------------------------------------------------
|
|
1411
|
+
// Only the agent turns of agy go through its profile. Every other Code Assist call (sign-in state,
|
|
1412
|
+
// model list, quota, experiments, analytics) and the checkpoint summaries stay on the person's own
|
|
1413
|
+
// Google account, exactly as without the switcher.
|
|
1414
|
+
const CODE_ASSIST_URL = (process.env.LLM_SWITCHER_CODE_ASSIST_URL || 'https://daily-cloudcode-pa.googleapis.com').replace(/\/+$/, '');
|
|
1415
|
+
const CODE_ASSIST_MODEL_CALLS = new Set(['streamGenerateContent', 'generateContent']);
|
|
1416
|
+
// Idle time on a passthrough, not its total time: a checkpoint stream may think for a while. Node
|
|
1417
|
+
// runs a delay outside 1..2147483647 ms as 1 ms, so a value outside 1 s..max falls back to 5 min.
|
|
1418
|
+
const CODE_ASSIST_IDLE_MS = (() => {
|
|
1419
|
+
const v = Number(process.env.LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS);
|
|
1420
|
+
return Number.isFinite(v) && v >= 1000 && v <= 2147483647 ? v : 300000;
|
|
1421
|
+
})();
|
|
1422
|
+
|
|
1423
|
+
async function routeCodeAssist(req, res, method, pathname) {
|
|
1424
|
+
const action = pathname.slice('/v1internal:'.length);
|
|
1425
|
+
if (method !== 'POST' || !CODE_ASSIST_MODEL_CALLS.has(action)) return passthroughCodeAssist(req, res, null);
|
|
1426
|
+
let buf;
|
|
1427
|
+
try {
|
|
1428
|
+
buf = await readBody(req, MAX_BODY_SIZE);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
return sendClientError(res, 'codeassist', err.status || 400, err.message);
|
|
1431
|
+
}
|
|
1432
|
+
// Without a config the gateway cannot tell whether agy is routed, so it must not guess Google.
|
|
1433
|
+
if (!loadConfig()) return sendClientError(res, 'codeassist', 503, `The gateway has no readable config (${configPath}).`);
|
|
1434
|
+
const { profile, error } = getActiveProfile('codeassist', req);
|
|
1435
|
+
if (error) return sendClientError(res, 'codeassist', 400, error);
|
|
1436
|
+
if (!profile) return passthroughCodeAssist(req, res, buf);
|
|
1437
|
+
let envelope;
|
|
1438
|
+
try {
|
|
1439
|
+
envelope = JSON.parse(buf.toString('utf8'));
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
return sendClientError(res, 'codeassist', 400, `Cannot parse the Code Assist request: ${err.message}`);
|
|
1442
|
+
}
|
|
1443
|
+
if (envelope?.requestType !== 'agent') return passthroughCodeAssist(req, res, buf);
|
|
1444
|
+
return handleConvert('codeassist', req, res, buf, { codeAssistAction: action, codeAssistStream: action === 'streamGenerateContent' });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Headers that belong to this gateway or to a proxy in front of it. Everything else agy sends,
|
|
1448
|
+
// x-goog-* included, is meant for Google.
|
|
1449
|
+
const GATEWAY_ONLY_HEADERS = new Set([
|
|
1450
|
+
'x-profile', 'x-llm-profile', 'x-llm-switcher-token', PROBE_HEADER, 'x-intact-trace', 'proxy-authorization', 'x-real-ip'
|
|
1451
|
+
]);
|
|
1452
|
+
const isGatewayOnlyHeader = (lk) => GATEWAY_ONLY_HEADERS.has(lk) || lk.startsWith('x-forwarded-');
|
|
1453
|
+
|
|
1454
|
+
// The one place where a client credential leaves this gateway on purpose: agy's own Google token
|
|
1455
|
+
// goes back to Google, the host it was issued for. It never reaches the Bifrost or convert paths,
|
|
1456
|
+
// which build their own headers. The bytes stream both ways unread (an analytics call is 100 KB+).
|
|
1457
|
+
function passthroughCodeAssist(req, res, body) {
|
|
1458
|
+
const target = new URL(`${CODE_ASSIST_URL}${req.url}`);
|
|
1459
|
+
const headers = {};
|
|
1460
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
1461
|
+
const lk = k.toLowerCase();
|
|
1462
|
+
if (lk === 'host' || lk === 'connection' || lk === 'keep-alive' || lk === 'proxy-connection' || isGatewayOnlyHeader(lk)) continue;
|
|
1463
|
+
headers[lk] = v;
|
|
1464
|
+
}
|
|
1465
|
+
if (body) {
|
|
1466
|
+
delete headers['transfer-encoding'];
|
|
1467
|
+
headers['content-length'] = String(body.length);
|
|
1468
|
+
}
|
|
1469
|
+
const transport = target.protocol === 'http:' ? http : https;
|
|
1470
|
+
// One outcome per call: the first of a timeout, an upstream error or a broken answer decides it.
|
|
1471
|
+
// Before the headers agy gets a Code Assist error; after them a half answer must not look whole,
|
|
1472
|
+
// so the connection is cut.
|
|
1473
|
+
let settled = false;
|
|
1474
|
+
const fail = (status, message) => {
|
|
1475
|
+
if (settled) return;
|
|
1476
|
+
settled = true;
|
|
1477
|
+
if (!res.headersSent) sendClientError(res, 'codeassist', status, message);
|
|
1478
|
+
else res.destroy();
|
|
1479
|
+
up.destroy();
|
|
1480
|
+
};
|
|
1481
|
+
const up = transport.request(target, { method: req.method, headers }, (ur) => {
|
|
1482
|
+
res.writeHead(ur.statusCode || 502, ur.headers);
|
|
1483
|
+
ur.on('aborted', () => fail(502, 'Code Assist closed the answer early'));
|
|
1484
|
+
ur.on('error', (err) => fail(502, `Code Assist answer failed: ${err.message}`));
|
|
1485
|
+
ur.on('end', () => { settled = true; });
|
|
1486
|
+
ur.pipe(res);
|
|
1487
|
+
});
|
|
1488
|
+
up.setTimeout(CODE_ASSIST_IDLE_MS, () => fail(504, `Code Assist did not answer within ${CODE_ASSIST_IDLE_MS} ms`));
|
|
1489
|
+
up.on('error', (err) => fail(502, `Cannot reach Code Assist: ${err.message}`));
|
|
1490
|
+
res.on('close', () => { if (!res.writableEnded) up.destroy(); });
|
|
1491
|
+
if (body) up.end(body);
|
|
1492
|
+
else req.pipe(up);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1355
1495
|
// The names /v1/models serves, and the window of each. Official names when the profile publishes
|
|
1356
1496
|
// them, otherwise the mapped upstream IDs; each window follows model1M of its slot.
|
|
1357
1497
|
function servedModels(req) {
|
|
@@ -1416,6 +1556,28 @@ async function routeApi(req, res, method, pathname) {
|
|
|
1416
1556
|
return sendJson(res, 200, { success: true, catalog: updated });
|
|
1417
1557
|
}
|
|
1418
1558
|
|
|
1559
|
+
// POST /api/update: progress as SSE; after a successful update the gateway hands over to the new code.
|
|
1560
|
+
if (pathname === '/api/update') {
|
|
1561
|
+
if (updateInProgress) return sendJson(res, 409, { error: 'An update is already in progress' });
|
|
1562
|
+
updateInProgress = true;
|
|
1563
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
|
1564
|
+
let result = null;
|
|
1565
|
+
try {
|
|
1566
|
+
result = await applyUpdate({ logger: (text) => sendSSE(res, 'log', { text }) });
|
|
1567
|
+
sendSSE(res, 'result', result);
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
sendSSE(res, 'error', { message: err.message });
|
|
1570
|
+
}
|
|
1571
|
+
res.end();
|
|
1572
|
+
if (result?.updated) {
|
|
1573
|
+
console.log(`[llm-switcher:update] v${result.from} -> v${result.to}. Handing over to the new code.`);
|
|
1574
|
+
handOver();
|
|
1575
|
+
} else {
|
|
1576
|
+
updateInProgress = false;
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1419
1581
|
// POST /api/logs/clear
|
|
1420
1582
|
if (pathname === '/api/logs/clear') {
|
|
1421
1583
|
requestLogs.length = 0;
|
|
@@ -1969,7 +2131,36 @@ if (getMigrationCollision()) {
|
|
|
1969
2131
|
console.error(`[llm-switcher:ERROR] Configuration migration collision: ${JSON.stringify(getMigrationCollision().clashingKeys)}. Fix config.json; it is not being rewritten.`);
|
|
1970
2132
|
}
|
|
1971
2133
|
|
|
1972
|
-
|
|
2134
|
+
// The new code starts as a child that binds the port as soon as this listener closes. This process
|
|
2135
|
+
// finishes its open streams and then waits as the parent, so a service manager keeps the pid it
|
|
2136
|
+
// started, and `switch off` still stops the gateway by the pid that listens.
|
|
2137
|
+
async function handOver() {
|
|
2138
|
+
server.close();
|
|
2139
|
+
server.closeIdleConnections?.();
|
|
2140
|
+
// The new gateway starts its own interceptor, so new interceptor code runs too.
|
|
2141
|
+
await stopRecordedBlindfold().catch(() => {});
|
|
2142
|
+
const args = process.argv.slice(2).filter(a => a !== '--autoupdate');
|
|
2143
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], { stdio: 'inherit', windowsHide: true });
|
|
2144
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
2145
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { child.kill(sig); process.exit(0); });
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// --autoupdate is on the service command line: each logon starts the newest release.
|
|
2149
|
+
let handedOver = false;
|
|
2150
|
+
if (process.argv.includes('--autoupdate')) {
|
|
2151
|
+
try {
|
|
2152
|
+
const r = await applyUpdate({ logger: (msg) => console.log(`[llm-switcher:autoupdate] ${msg}`) });
|
|
2153
|
+
console.log(`[llm-switcher:autoupdate] ${r.reason}`);
|
|
2154
|
+
if (r.updated) {
|
|
2155
|
+
handedOver = true;
|
|
2156
|
+
await handOver();
|
|
2157
|
+
}
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
console.warn(`[llm-switcher:autoupdate] ${err.message} Starting the installed version.`);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
if (!handedOver) server.listen(PORT, '127.0.0.1', () => {
|
|
1973
2164
|
// A service start or a restart on a new port finds env-codex.* already pointing at the interceptor.
|
|
1974
2165
|
const cfg = loadConfig();
|
|
1975
2166
|
if (getMigrationCollision()) {
|
package/service.mjs
CHANGED
|
@@ -14,13 +14,14 @@ export function serviceEnv(env = process.env) {
|
|
|
14
14
|
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
15
|
const systemdQuote = (s) => `"${String(s).replace(/(["\\])/g, '\\$1')}"`;
|
|
16
16
|
|
|
17
|
-
export function systemdUnit({ nodeBin, script, port, env = [] }) {
|
|
17
|
+
export function systemdUnit({ nodeBin, script, port, env = [], autoupdate = false }) {
|
|
18
|
+
const args = autoupdate ? `${systemdQuote(script)} --port ${port} --autoupdate` : `${systemdQuote(script)} --port ${port}`;
|
|
18
19
|
return `[Unit]
|
|
19
20
|
Description=LLM Switcher Local Gateway
|
|
20
21
|
After=network.target
|
|
21
22
|
|
|
22
23
|
[Service]
|
|
23
|
-
ExecStart=${systemdQuote(nodeBin)} ${
|
|
24
|
+
ExecStart=${systemdQuote(nodeBin)} ${args}
|
|
24
25
|
${env.map(([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}\n`).join('')}Restart=always
|
|
25
26
|
|
|
26
27
|
[Install]
|
|
@@ -28,7 +29,7 @@ WantedBy=default.target
|
|
|
28
29
|
`;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export function launchdPlist({ nodeBin, script, port, logPath, env = [] }) {
|
|
32
|
+
export function launchdPlist({ nodeBin, script, port, logPath, env = [], autoupdate = false }) {
|
|
32
33
|
const envBlock = env.length
|
|
33
34
|
? ` <key>EnvironmentVariables</key>
|
|
34
35
|
<dict>
|
|
@@ -48,7 +49,7 @@ ${env.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v
|
|
|
48
49
|
<string>${xmlEscape(script)}</string>
|
|
49
50
|
<string>--port</string>
|
|
50
51
|
<string>${port}</string>
|
|
51
|
-
</array>
|
|
52
|
+
${autoupdate ? ' <string>--autoupdate</string>\n' : ''} </array>
|
|
52
53
|
${envBlock} <key>StandardOutPath</key>
|
|
53
54
|
<string>${xmlEscape(logPath)}</string>
|
|
54
55
|
<key>StandardErrorPath</key>
|
|
@@ -65,7 +66,8 @@ ${envBlock} <key>StandardOutPath</key>
|
|
|
65
66
|
// Task Scheduler reads the command and its arguments from two elements, so no path goes through
|
|
66
67
|
// the quoting rules of a /TR command line. A task made with /TR also stops after 72 hours by
|
|
67
68
|
// default; PT0S removes that limit.
|
|
68
|
-
export function scheduledTaskXml({ nodeBin, script, port, userId }) {
|
|
69
|
+
export function scheduledTaskXml({ nodeBin, script, port, userId, autoupdate = false }) {
|
|
70
|
+
const args = autoupdate ? `"${script}" --port ${port} --autoupdate` : `"${script}" --port ${port}`;
|
|
69
71
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
70
72
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
71
73
|
<RegistrationInfo>
|
|
@@ -94,7 +96,7 @@ export function scheduledTaskXml({ nodeBin, script, port, userId }) {
|
|
|
94
96
|
<Actions Context="Author">
|
|
95
97
|
<Exec>
|
|
96
98
|
<Command>${xmlEscape(nodeBin)}</Command>
|
|
97
|
-
<Arguments>${xmlEscape(
|
|
99
|
+
<Arguments>${xmlEscape(args)}</Arguments>
|
|
98
100
|
</Exec>
|
|
99
101
|
</Actions>
|
|
100
102
|
</Task>
|
|
@@ -115,6 +117,10 @@ export function portFromServiceText(text) {
|
|
|
115
117
|
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
export function autoupdateFromServiceText(text) {
|
|
121
|
+
return /--autoupdate\b/.test(String(text));
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
/** Writes a service definition. Returns the backup path when it replaced different content. */
|
|
119
125
|
export function writeServiceFile(file, content) {
|
|
120
126
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/shim.mjs
CHANGED
|
@@ -37,18 +37,24 @@ export const SHIM_DIR = path.join(os.homedir(), '.llm-switcher', 'bin');
|
|
|
37
37
|
// space in the checkout into %20, which cmd.exe then reads as the (undefined) argument %2.
|
|
38
38
|
const STATE_HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ensure-ca-bundle.mjs');
|
|
39
39
|
const ROUTE_NOTIFIER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'notify-route.ps1');
|
|
40
|
+
// agy sends its Google token in clear text to CLOUD_CODE_URL. Its shim asks this helper for the
|
|
41
|
+
// gateway's identity proof first, so a process that took the port never receives the token.
|
|
42
|
+
const GATEWAY_CHECK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'verify-gateway.mjs');
|
|
40
43
|
|
|
41
|
-
// CLIs to wrap. `claude` is the most important case (--resume)
|
|
42
|
-
export const SHIMMED = ['claude', 'codex'];
|
|
44
|
+
// CLIs to wrap. `claude` is the most important case (--resume); codex and agy have their own files.
|
|
45
|
+
export const SHIMMED = ['claude', 'codex', 'agy'];
|
|
43
46
|
|
|
44
47
|
// One tool, one env file. Sourcing the shared one is how one tool used to capture the other.
|
|
45
|
-
const envFileOf = (name) => (name
|
|
46
|
-
const routeFileOf = (name) => (name
|
|
48
|
+
const envFileOf = (name) => `env-${SHIMMED.includes(name) ? name : 'claude'}.sh`;
|
|
49
|
+
const routeFileOf = (name) => `route-${SHIMMED.includes(name) ? name : 'claude'}.txt`;
|
|
50
|
+
// Only Claude Code trusts the interceptor through NODE_EXTRA_CA_CERTS. Codex takes its CA from its
|
|
51
|
+
// env file, and agy reaches the gateway over plain http.
|
|
52
|
+
const needsCaBlock = (name) => name === 'claude';
|
|
47
53
|
|
|
48
54
|
const POSIX_TEMPLATE = (name) => {
|
|
49
55
|
const envFile = envFileOf(name);
|
|
50
56
|
const routeFile = routeFileOf(name);
|
|
51
|
-
const caBlock = name
|
|
57
|
+
const caBlock = !needsCaBlock(name) ? '' : `
|
|
52
58
|
# R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor never
|
|
53
59
|
# costs a certificate the user already had. Decided here, at launch, because only now is it known
|
|
54
60
|
# whether the user brought one — and never while this tool is off.
|
|
@@ -87,7 +93,14 @@ if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/${envFile}" ] && [ -f "$SWITCHE
|
|
|
87
93
|
else
|
|
88
94
|
TOOL_ACTIVE=0
|
|
89
95
|
fi
|
|
90
|
-
|
|
96
|
+
${name !== 'agy' ? '' : `
|
|
97
|
+
# The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
|
|
98
|
+
if [ "$TOOL_ACTIVE" = "1" ] && ! node "${GATEWAY_CHECK}" "\${CLOUD_CODE_URL:-}" >/dev/null 2>&1; then
|
|
99
|
+
printf '[llm-switcher] the gateway at %s did not prove it is this switcher; agy uses its official endpoint.\\n' "\${CLOUD_CODE_URL:-}" >&2
|
|
100
|
+
unset CLOUD_CODE_URL
|
|
101
|
+
TOOL_ACTIVE=0
|
|
102
|
+
fi
|
|
103
|
+
`}
|
|
91
104
|
# Nothing on screen tells the person that the switcher took this tool's traffic, so say it once,
|
|
92
105
|
# here. Only while THIS tool is routed: an empty route file means the tool is off, so the tool
|
|
93
106
|
# reaches its official endpoint and there is nothing to warn about.
|
|
@@ -122,6 +135,14 @@ if [ -n "$_u" ]; then
|
|
|
122
135
|
*" \${_u#http://} "*) unset OPENAI_BASE_URL ;;
|
|
123
136
|
esac
|
|
124
137
|
fi
|
|
138
|
+
# agy's gateway address is the switcher's own value. While agy is off, a value left in this shell
|
|
139
|
+
# from an earlier run must not keep sending agy to the gateway.
|
|
140
|
+
_u="\${CLOUD_CODE_URL:-}"
|
|
141
|
+
if [ "$TOOL_ACTIVE" != "1" ] && [ -n "$_u" ]; then
|
|
142
|
+
case " $SWITCHER_URLS " in
|
|
143
|
+
*" \${_u#http://} "*) unset CLOUD_CODE_URL ;;
|
|
144
|
+
esac
|
|
145
|
+
fi
|
|
125
146
|
|
|
126
147
|
# The suffix the old switcher forced on every tier. Nothing else matches: the user's own
|
|
127
148
|
# ANTHROPIC_MODEL=opus, or a real model id, is theirs.
|
|
@@ -178,7 +199,7 @@ exec "$REAL" "$@"
|
|
|
178
199
|
const WINDOWS_TEMPLATE = (name) => {
|
|
179
200
|
const envFile = envFileOf(name).replace(/\.sh$/, '.cmd');
|
|
180
201
|
const routeFile = routeFileOf(name);
|
|
181
|
-
const caBlock = name
|
|
202
|
+
const caBlock = !needsCaBlock(name) ? '' : `
|
|
182
203
|
REM R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor
|
|
183
204
|
REM never costs a certificate the user already had. Decided here, at launch, because only now
|
|
184
205
|
REM is it known whether the user brought one - and never while this tool is off.
|
|
@@ -198,7 +219,7 @@ set "NODE_EXTRA_CA_CERTS=%CA_BUNDLE%"
|
|
|
198
219
|
REM Auto-generated by LLM Switcher - DO NOT EDIT.
|
|
199
220
|
setlocal
|
|
200
221
|
set "SWITCHER_DIR=${STATE_DIR}"
|
|
201
|
-
${name
|
|
222
|
+
${!needsCaBlock(name) ? '' : `set "STATE_HELPER=${STATE_HELPER}"
|
|
202
223
|
|
|
203
224
|
REM Captured BEFORE the tool's env file runs: the CA decision must see the value this shell
|
|
204
225
|
REM already had, not one this run is about to write.
|
|
@@ -212,7 +233,10 @@ if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\${envFile}" (
|
|
|
212
233
|
for %%A in ("%SWITCHER_DIR%\\${envFile}") do if %%~zA GTR 0 set "TOOL_ACTIVE=1"
|
|
213
234
|
)
|
|
214
235
|
if "%TOOL_ACTIVE%"=="1" call "%SWITCHER_DIR%\\${envFile}"
|
|
215
|
-
|
|
236
|
+
${name !== 'agy' ? '' : `
|
|
237
|
+
REM The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
|
|
238
|
+
if "%TOOL_ACTIVE%"=="1" call :CHECK_GATEWAY
|
|
239
|
+
`}
|
|
216
240
|
REM Nothing on screen tells the person that the switcher took this tool's traffic, and both TUIs
|
|
217
241
|
REM can claim the whole screen, so a printed line would be hidden. A toast is not. It is detached:
|
|
218
242
|
REM the tool never waits for it. Only while THIS tool is routed (%TOOL_ACTIVE%).
|
|
@@ -225,6 +249,8 @@ if exist "%SWITCHER_DIR%\\gateway.port" for /f "usebackq delims=" %%P in ("%SWIT
|
|
|
225
249
|
if not defined _GWPORT set "_GWPORT=3456"
|
|
226
250
|
if defined ANTHROPIC_BASE_URL call :SCRUB_URL ANTHROPIC_BASE_URL "%ANTHROPIC_BASE_URL%"
|
|
227
251
|
if defined OPENAI_BASE_URL call :SCRUB_URL OPENAI_BASE_URL "%OPENAI_BASE_URL%"
|
|
252
|
+
REM agy's gateway address is the switcher's own value: scrubbed only while agy is off.
|
|
253
|
+
if not "%TOOL_ACTIVE%"=="1" if defined CLOUD_CODE_URL call :SCRUB_URL CLOUD_CODE_URL "%CLOUD_CODE_URL%"
|
|
228
254
|
if /i "%ANTHROPIC_MODEL%"=="opus[1m]" set "ANTHROPIC_MODEL="
|
|
229
255
|
if /i "%ANTHROPIC_MODEL%"=="sonnet[1m]" set "ANTHROPIC_MODEL="
|
|
230
256
|
if /i "%ANTHROPIC_MODEL%"=="haiku[1m]" set "ANTHROPIC_MODEL="
|
|
@@ -281,7 +307,16 @@ REM another tier (ANTHROPIC_DEFAULT_OPUS_MODEL=sonnet[1m]) is not the switcher's
|
|
|
281
307
|
:SCRUB_TIER
|
|
282
308
|
if /i "%~2"=="%~3[1m]" set "%~1="
|
|
283
309
|
goto :eof
|
|
284
|
-
|
|
310
|
+
${name !== 'agy' ? '' : `
|
|
311
|
+
REM Exit 0 from the helper means the gateway answered with this install's identity proof.
|
|
312
|
+
:CHECK_GATEWAY
|
|
313
|
+
node "${GATEWAY_CHECK}" "%CLOUD_CODE_URL%" >nul 2>&1
|
|
314
|
+
if not errorlevel 1 goto :eof
|
|
315
|
+
echo [llm-switcher] the gateway at %CLOUD_CODE_URL% did not prove it is this switcher; agy uses its official endpoint. 1>&2
|
|
316
|
+
set "CLOUD_CODE_URL="
|
|
317
|
+
set "TOOL_ACTIVE=0"
|
|
318
|
+
goto :eof
|
|
319
|
+
`}`;
|
|
285
320
|
};
|
|
286
321
|
|
|
287
322
|
export function renderShim(name, platform = process.platform) {
|
|
@@ -347,20 +382,26 @@ export function uninstallShims(names = SHIMMED) {
|
|
|
347
382
|
}
|
|
348
383
|
|
|
349
384
|
/** Is SHIM_DIR already ahead of the real binary in PATH? */
|
|
350
|
-
export function shimStatus(names = SHIMMED
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
385
|
+
export function shimStatus(names = SHIMMED, {
|
|
386
|
+
pathEnv = process.env.PATH, shimDir = SHIM_DIR, platform = process.platform, pathExt = process.env.PATHEXT
|
|
387
|
+
} = {}) {
|
|
388
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
389
|
+
const entries = (pathEnv || '').split(delimiter).filter(Boolean);
|
|
390
|
+
const idx = entries.findIndex(p => path.resolve(p) === path.resolve(shimDir));
|
|
391
|
+
const out = { dir: shimDir, onPath: idx !== -1, position: idx, shims: [] };
|
|
392
|
+
// Windows takes the first directory that holds the name with any PATHEXT extension, in PATHEXT
|
|
393
|
+
// order, so an agy.exe earlier on PATH runs before the agy.cmd shim.
|
|
394
|
+
const exts = platform === 'win32'
|
|
395
|
+
? (pathExt || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map(e => e.toLowerCase())
|
|
396
|
+
: [''];
|
|
354
397
|
|
|
355
398
|
for (const name of names) {
|
|
356
|
-
const target =
|
|
399
|
+
const target = path.join(shimDir, platform === 'win32' ? `${name}.cmd` : name);
|
|
357
400
|
const exists = fs.existsSync(target);
|
|
358
401
|
let effective = null; // which binary will run when the command name is typed
|
|
359
402
|
for (const p of entries) {
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
if (fs.existsSync(cand)) { effective = cand; break; }
|
|
363
|
-
if (fs.existsSync(candExe)) { effective = candExe; break; }
|
|
403
|
+
const hit = exts.map(e => path.join(p, `${name}${e}`)).find(c => fs.existsSync(c));
|
|
404
|
+
if (hit) { effective = hit; break; }
|
|
364
405
|
}
|
|
365
406
|
out.shims.push({
|
|
366
407
|
name,
|
|
@@ -421,7 +462,8 @@ export function pathOrderHint(platform = process.platform) {
|
|
|
421
462
|
// Codex takes the gateway URL as a --config override, or the interceptor as HTTPS_PROXY.
|
|
422
463
|
const ROUTE_EVIDENCE = {
|
|
423
464
|
claude: /(^|\s)ANTHROPIC_BASE_URL=/,
|
|
424
|
-
codex: /(^|\s)(LLM_SWITCHER_CODEX_BASE_URL=|https?_proxy=http:\/\/(127\.0\.0\.1|localhost)[:/]|openai_base_url=http:\/\/(127\.0\.0\.1|localhost)[:/])/i
|
|
465
|
+
codex: /(^|\s)(LLM_SWITCHER_CODEX_BASE_URL=|https?_proxy=http:\/\/(127\.0\.0\.1|localhost)[:/]|openai_base_url=http:\/\/(127\.0\.0\.1|localhost)[:/])/i,
|
|
466
|
+
agy: /(^|\s)CLOUD_CODE_URL=http:\/\/(127\.0\.0\.1|localhost)[:/]/
|
|
425
467
|
};
|
|
426
468
|
|
|
427
469
|
/** true | false, or null when the dump holds no environment (macOS ps for most processes). */
|