llm-switcher 1.2.12 → 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 +23 -0
- package/README.md +109 -19
- package/README.vi.md +107 -19
- package/config.example.json +13 -1
- 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 +155 -18
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +6 -6
- package/state.mjs +50 -13
- package/switch.mjs +15 -9
- 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/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/ui.html +51 -7
- package/verify-gateway.mjs +35 -0
package/proxy.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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';
|
|
@@ -12,7 +13,8 @@ import {
|
|
|
12
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
|
|
13
14
|
toGeminiSchema, isAntigravityModel,
|
|
14
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
15
|
-
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage
|
|
16
|
+
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
17
|
+
createCodeAssistStream, buildCodeAssistMessage
|
|
16
18
|
} from './formats.mjs';
|
|
17
19
|
import {
|
|
18
20
|
TOOLS, configPath, loadConfig, getConfigLoadError, saveConfig, resolvePort, hasProfile, isValidProfileKey,
|
|
@@ -204,8 +206,8 @@ async function readJsonBody(req, limit = MAX_API_BODY_SIZE) {
|
|
|
204
206
|
// ----------------------------------------------------
|
|
205
207
|
// Profile & model resolution
|
|
206
208
|
// ----------------------------------------------------
|
|
207
|
-
// R5:
|
|
208
|
-
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' };
|
|
209
211
|
|
|
210
212
|
function getActiveProfile(clientFormat, req) {
|
|
211
213
|
const cfg = loadConfig();
|
|
@@ -249,6 +251,15 @@ function getFirstActiveProfile(preferred, req) {
|
|
|
249
251
|
// clientFormat is the protocol the request arrived in. An `auto` profile serves every protocol,
|
|
250
252
|
// so the profile's inFormat cannot tell a Codex request from a Claude one.
|
|
251
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
|
+
}
|
|
252
263
|
if (!requestedModel) return primaryModel(profile);
|
|
253
264
|
const clean = requestedModel.replace(/\[1m\]/gi, '').trim();
|
|
254
265
|
// If the client specified a model with a provider prefix (e.g. ag/..., gh/..., cf/...), keep it as-is
|
|
@@ -347,7 +358,7 @@ function sendClientError(res, clientFormat, status, message, headers = {}) {
|
|
|
347
358
|
let body;
|
|
348
359
|
if (clientFormat === 'anthropic') {
|
|
349
360
|
body = { type: 'error', error: { type: anthropicErrorType(status), message } };
|
|
350
|
-
} else if (clientFormat === 'vertex') {
|
|
361
|
+
} else if (clientFormat === 'vertex' || clientFormat === 'codeassist') {
|
|
351
362
|
body = { error: { code: status, message, status: vertexStatus(status) } };
|
|
352
363
|
} else {
|
|
353
364
|
body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: String(status) } };
|
|
@@ -478,6 +489,7 @@ function clientRenderer(clientFormat, res, model, opts = {}) {
|
|
|
478
489
|
if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model);
|
|
479
490
|
if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
480
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);
|
|
481
493
|
return createChatStream((e, d) => sendSSE(res, null, d), model);
|
|
482
494
|
}
|
|
483
495
|
|
|
@@ -485,6 +497,7 @@ function clientMessage(clientFormat, args) {
|
|
|
485
497
|
if (clientFormat === 'anthropic') return buildAnthropicMessage(args);
|
|
486
498
|
if (clientFormat === 'responses') return buildResponsesMessage(args);
|
|
487
499
|
if (clientFormat === 'vertex') return buildVertexMessage(args);
|
|
500
|
+
if (clientFormat === 'codeassist') return buildCodeAssistMessage(args);
|
|
488
501
|
return buildChatMessage(args);
|
|
489
502
|
}
|
|
490
503
|
|
|
@@ -506,6 +519,11 @@ const HOP_BY_HOP = new Set(['content-length', 'content-encoding', 'transfer-enco
|
|
|
506
519
|
// Bifrost: intact names, per model, the User-Agent prefix of the provider's own client (bifrost_ua).
|
|
507
520
|
// When the caller is that client, the request crosses as it is: only the key changes.
|
|
508
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
|
+
})();
|
|
509
527
|
async function bifrostUAFor(profile, model) {
|
|
510
528
|
const base = String(profile.baseURL || '').replace(/\/+$/, '');
|
|
511
529
|
if (!base || !model) return '';
|
|
@@ -513,16 +531,20 @@ async function bifrostUAFor(profile, model) {
|
|
|
513
531
|
const hit = bifrostUACache.get(key);
|
|
514
532
|
if (hit && hit.until > Date.now()) return hit.ua;
|
|
515
533
|
let ua = '';
|
|
516
|
-
let
|
|
534
|
+
let failure = '';
|
|
517
535
|
try {
|
|
518
536
|
const r = await fetch(`${base}/models/${model.split('/').map(encodeURIComponent).join('/')}`, {
|
|
519
537
|
headers: { authorization: `Bearer ${profile.apiKey || ''}` }, signal: AbortSignal.timeout(3000)
|
|
520
538
|
});
|
|
539
|
+
// Only a 2xx is an answer. A 404 can be an intact older than 0.1.14 or a wrong baseURL.
|
|
521
540
|
if (r.ok) ua = String((await r.json())?.bifrost_ua || '');
|
|
522
|
-
|
|
523
|
-
|
|
541
|
+
else failure = `HTTP ${r.status}`;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
failure = err.message;
|
|
524
544
|
}
|
|
525
|
-
|
|
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) });
|
|
526
548
|
return ua;
|
|
527
549
|
}
|
|
528
550
|
|
|
@@ -545,6 +567,15 @@ function bifrostHeaders(req, profile) {
|
|
|
545
567
|
return headers;
|
|
546
568
|
}
|
|
547
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
|
+
|
|
548
579
|
function withClientQuery(url, req) {
|
|
549
580
|
const q = String(req.url || '').indexOf('?');
|
|
550
581
|
return q < 0 ? url : url + (url.includes('?') ? '&' : '?') + req.url.slice(q + 1);
|
|
@@ -605,7 +636,9 @@ async function forwardAnthropicDirect(res, payload, bodyBuffer, url, headers, ma
|
|
|
605
636
|
streamError = `stream interrupted: ${streamErr.cause?.message || streamErr.message}`;
|
|
606
637
|
}
|
|
607
638
|
} finally {
|
|
608
|
-
|
|
639
|
+
// A cut stream must not reach the client as a whole answer.
|
|
640
|
+
if (streamError && !signal.aborted) res.destroy();
|
|
641
|
+
else res.end();
|
|
609
642
|
}
|
|
610
643
|
const error = streamError || (upstreamRes.ok ? null : errorPreview.slice(0, 300));
|
|
611
644
|
return { status, error, healed: healed.notes, tokens: usage.tokens() };
|
|
@@ -622,13 +655,16 @@ function createUsageTap(limit = 4 * 1024 * 1024) {
|
|
|
622
655
|
if (!u || typeof u !== 'object') return;
|
|
623
656
|
if (typeof u.input_tokens === 'number') tokens.prompt = u.input_tokens + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
624
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);
|
|
625
661
|
};
|
|
626
662
|
const line = (l) => {
|
|
627
663
|
const t = l.startsWith('data:') ? l.slice(5).trim() : '';
|
|
628
|
-
if (!t.includes('"usage"')) return;
|
|
664
|
+
if (!t.includes('"usage"') && !t.includes('"usageMetadata"')) return;
|
|
629
665
|
try {
|
|
630
666
|
const d = JSON.parse(t);
|
|
631
|
-
take(d.message?.usage || d.usage);
|
|
667
|
+
take(d.message?.usage || d.usage || d.response?.usage || d.response?.usageMetadata || d.usageMetadata);
|
|
632
668
|
} catch {}
|
|
633
669
|
};
|
|
634
670
|
return {
|
|
@@ -643,7 +679,10 @@ function createUsageTap(limit = 4 * 1024 * 1024) {
|
|
|
643
679
|
tokens() {
|
|
644
680
|
text += decoder.decode();
|
|
645
681
|
if (text.trim().startsWith('{')) {
|
|
646
|
-
try {
|
|
682
|
+
try {
|
|
683
|
+
const j = JSON.parse(text);
|
|
684
|
+
take(j.usage || j.response?.usageMetadata || j.usageMetadata);
|
|
685
|
+
} catch {}
|
|
647
686
|
} else if (text) line(text);
|
|
648
687
|
return tokens;
|
|
649
688
|
}
|
|
@@ -773,12 +812,18 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
773
812
|
ir.stream = Boolean(opts.vertexStream);
|
|
774
813
|
if (!ir.model && opts.vertexModel) ir.model = opts.vertexModel;
|
|
775
814
|
}
|
|
815
|
+
// Code Assist names streaming in the path (:streamGenerateContent), not in the body.
|
|
816
|
+
if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
|
|
776
817
|
|
|
777
818
|
const reqStartTime = Date.now();
|
|
778
819
|
const requestPreview = previewOf(ir);
|
|
779
820
|
const requestedModel = ir.model || payload.model || '';
|
|
780
821
|
const mappedModel = mapModel(requestedModel, profile, clientFormat);
|
|
781
|
-
|
|
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);
|
|
782
827
|
const outFormat = resolveOutFormat(profile, mappedModel);
|
|
783
828
|
const route = bifrost ? 'bifrost' : outFormat;
|
|
784
829
|
console.log(`[llm-switcher] ${clientFormat} -> ${route} "${requestedModel}" -> "${mappedModel}" [${profile.name || profileKey}]`);
|
|
@@ -788,7 +833,9 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
788
833
|
|
|
789
834
|
// Contract lab: a sampled request carries a trace id to intact, and the bytes this gateway
|
|
790
835
|
// writes back are copied for the upload that follows the answer.
|
|
791
|
-
|
|
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));
|
|
792
839
|
const halfTap = traceId ? createHalfTap() : null;
|
|
793
840
|
if (halfTap) tapClientWrites(res, halfTap);
|
|
794
841
|
|
|
@@ -807,11 +854,11 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
807
854
|
// Bifrost: the provider's own client reaching its own account through intact. Bytes and headers
|
|
808
855
|
// go as sent, only the key changes: no healer, no thinkingMode, no conversion.
|
|
809
856
|
if (bifrost) {
|
|
810
|
-
|
|
857
|
+
// bifrostHeaders drops the client's own Authorization: agy's Google token never reaches intact.
|
|
811
858
|
const headers = bifrostHeaders(req, profile);
|
|
812
859
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
813
860
|
try {
|
|
814
|
-
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);
|
|
815
862
|
answered = !r.error && r.status >= 200 && r.status < 300;
|
|
816
863
|
log({ status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
|
|
817
864
|
} catch (err) {
|
|
@@ -941,7 +988,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
941
988
|
toolResponse: halfTap?.text() || '',
|
|
942
989
|
toolVersion: toolVersionFromUA(req.headers['user-agent']),
|
|
943
990
|
inFormat: clientFormat,
|
|
944
|
-
outFormat: bifrost ?
|
|
991
|
+
outFormat: bifrost ? clientFormat : outFormat, // Bifrost converts nothing
|
|
945
992
|
openerKey: profile.apiKey || ''
|
|
946
993
|
});
|
|
947
994
|
}
|
|
@@ -1058,7 +1105,7 @@ function validateProfileInput(p) {
|
|
|
1058
1105
|
if (typeof p[k] === 'string' && CONTROL_CHARS.test(p[k])) return `${k} must not contain control characters`;
|
|
1059
1106
|
}
|
|
1060
1107
|
// R6 / R7(c): a profile declares exactly one tool. An old-format field must never be written back.
|
|
1061
|
-
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"';
|
|
1062
1109
|
if (p.blindfold !== undefined) return 'blindfold per-profile setting is deprecated';
|
|
1063
1110
|
if (p.blindfoldPort !== undefined) return 'blindfoldPort per-profile setting is deprecated';
|
|
1064
1111
|
if (p.blindfoldHost !== undefined) return 'blindfoldHost per-profile setting is deprecated';
|
|
@@ -1315,6 +1362,9 @@ async function route(req, res) {
|
|
|
1315
1362
|
return handleCountTokens(req, res, buf);
|
|
1316
1363
|
}
|
|
1317
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
|
+
|
|
1318
1368
|
let clientFormat = null;
|
|
1319
1369
|
|
|
1320
1370
|
// Codex CLI sends GET /v1/responses (and /v1/responses/{id}) to fetch model
|
|
@@ -1355,6 +1405,93 @@ async function route(req, res) {
|
|
|
1355
1405
|
return sendJson(res, 404, { error: { message: `Not found: ${method} ${pathname}` } });
|
|
1356
1406
|
}
|
|
1357
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
|
+
|
|
1358
1495
|
// The names /v1/models serves, and the window of each. Official names when the profile publishes
|
|
1359
1496
|
// them, otherwise the mapped upstream IDs; each window follows model1M of its slot.
|
|
1360
1497
|
function servedModels(req) {
|
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). */
|
|
@@ -5,7 +5,7 @@ description: Mandatory guidance for AI coding agents and token compression tools
|
|
|
5
5
|
|
|
6
6
|
# LLM Switcher Edge Gateway Skill
|
|
7
7
|
|
|
8
|
-
This skill governs how AI coding agents (Claude Code, Codex, Cursor, Windsurf, Opencode) and prompt optimization/compression tools (Headroom, RTK, Ponytail, Repomix) must interact with LLM providers on this workstation.
|
|
8
|
+
This skill governs how AI coding agents (Claude Code, Codex, the Antigravity CLI `agy`, Cursor, Windsurf, Opencode) and prompt optimization/compression tools (Headroom, RTK, Ponytail, Repomix) must interact with LLM providers on this workstation.
|
|
9
9
|
|
|
10
10
|
## 1. The Core Directive
|
|
11
11
|
|
|
@@ -73,10 +73,10 @@ When an agent spawns sub-processes that call LLM CLIs:
|
|
|
73
73
|
- **Never advise sourcing `env.sh` or `env.cmd`.** Those files carry no proxy variables any
|
|
74
74
|
more, so sourcing one changes nothing — and a stale variable an older version once wrote
|
|
75
75
|
would still point the tool at a port where nothing listens.
|
|
76
|
-
- Run `switch claude <profile>` or `switch
|
|
77
|
-
`~/.llm-switcher/bin` inject the
|
|
78
|
-
anything stale first.
|
|
79
|
-
- `claude` and `
|
|
76
|
+
- Run `switch claude <profile>`, `switch codex <profile>` or `switch agy <profile>` instead. The
|
|
77
|
+
shims in `~/.llm-switcher/bin` inject the gateway variables into the tool process itself and
|
|
78
|
+
clear anything stale first. For `agy` the variable is `CLOUD_CODE_URL`, not a proxy.
|
|
79
|
+
- `claude`, `codex` and `agy` on `PATH` already are those shims: invoke them normally, and never
|
|
80
80
|
wrap them in another script.
|
|
81
81
|
|
|
82
82
|
## 4. Operational Rules for AI Agents
|
|
@@ -86,7 +86,7 @@ When an agent spawns sub-processes that call LLM CLIs:
|
|
|
86
86
|
2. **Check Gateway Health Before Complex Operations:**
|
|
87
87
|
Run `switch status` or call the `switcher_audit` MCP tool to confirm:
|
|
88
88
|
- LLM Switcher is active on port `3456`.
|
|
89
|
-
- The active profile matches the intended CLI target (Claude Code, Codex, or
|
|
89
|
+
- The active profile matches the intended CLI target (Claude Code, Codex, or the Antigravity CLI).
|
|
90
90
|
3. **Verify Routing When Errors Occur:**
|
|
91
91
|
If a tool fails with `HTTP 400`, `HTTP 502`, or connection errors:
|
|
92
92
|
- Run `switch doctor` to audit port collisions and environment variables.
|