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/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: the only two input protocols, each owned by one tool.
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 ttl = 10 * 60 * 1000;
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
- } catch {
520
- 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;
521
544
  }
522
- 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) });
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
- 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();
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 { 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 {}
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
- 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);
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
- 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));
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
- const { url } = upstreamEndpoint(profile, 'anthropic', mappedModel, ir.stream, req);
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, 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);
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 ? 'anthropic' : outFormat, // Bifrost converts nothing
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" or "tool": "codex"';
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
- server.listen(PORT, '127.0.0.1', () => {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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)} ${systemdQuote(script)} --port ${port}
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(`"${script}" --port ${port}`)}</Arguments>
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), 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). */