llm-switcher 1.2.12 → 1.3.1

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
@@ -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: the only two input protocols, each owned by one tool.
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 ttl = 10 * 60 * 1000;
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
- } catch {
523
- ttl = 30 * 1000; // an unreachable list must not pin the decision for long
541
+ else failure = `HTTP ${r.status}`;
542
+ } catch (err) {
543
+ failure = err.message;
524
544
  }
525
- bifrostUACache.set(key, { ua, until: Date.now() + ttl });
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
- res.end();
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 { take(JSON.parse(text).usage); } catch {}
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
- const bifrost = clientFormat === 'anthropic' && await crossesBifrost(req, profile, mappedModel);
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
- const traceId = probeTraceId(req) || contractLab.traceFor(mappedModel);
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
- const { url } = upstreamEndpoint(profile, 'anthropic', mappedModel, ir.stream, req);
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, withClientQuery(url, req), headers, mappedModel, ac.signal, profile, true);
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 ? 'anthropic' : outFormat, // Bifrost converts nothing
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" or "tool": "codex"';
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), codex included for completeness.
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 === 'codex' ? 'env-codex.sh' : 'env-claude.sh');
46
- const routeFileOf = (name) => (name === 'codex' ? 'route-codex.txt' : 'route-claude.txt');
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 === 'codex' ? '' : `
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 === 'codex' ? '' : `
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 === 'codex' ? '' : `set "STATE_HELPER=${STATE_HELPER}"
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
- const entries = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
352
- const idx = entries.findIndex(p => path.resolve(p) === path.resolve(SHIM_DIR));
353
- const out = { dir: SHIM_DIR, onPath: idx !== -1, position: idx, shims: [] };
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 = shimPath(name);
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 cand = path.join(p, process.platform === 'win32' ? `${name}.cmd` : name);
361
- const candExe = path.join(p, name);
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 codex <profile>` instead. The shims in
77
- `~/.llm-switcher/bin` inject the proxy variables into the tool process itself and clear
78
- anything stale first.
79
- - `claude` and `codex` on `PATH` already are those shims: invoke them normally, and never
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 OpenAI).
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.