llm-switcher 1.3.8 → 1.4.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/update.mjs CHANGED
@@ -1,5 +1,8 @@
1
- // Brings this install to the newest release: a git checkout pulls fast-forward only, an npm install
2
- // asks the registry and installs that exact version. Restarting the gateway is the caller's job.
1
+ // Brings this install to the newest release. A release is published to npm, so npm is what is asked
2
+ // first: `npm install -g llm-switcher@<version>` for the exact version the registry names. A git
3
+ // checkout is the one install npm cannot serve, because npm writes into the global prefix while the
4
+ // running code is the checkout itself, so the checkout is asked second and only then. Restarting
5
+ // the gateway is the caller's job.
3
6
  import fs from 'node:fs';
4
7
  import os from 'node:os';
5
8
  import path from 'node:path';
@@ -80,8 +83,9 @@ async function updateNpmInstall(root, run, log, registryUrl) {
80
83
  throw failure('npm install', err);
81
84
  }
82
85
  const to = readVersion(root);
83
- // npm can install into another prefix than the one this gateway runs from; then nothing changed here.
84
- if (to === from) return noUpdate(from, `npm installed v${latest}, but not into ${root}.`);
86
+ // npm installs into the global prefix. When this gateway runs from a checkout that is a different
87
+ // directory, so the registry can name a release this code never receives; `elsewhere` says so.
88
+ if (to === from) return { ...noUpdate(from, `npm installed v${latest}, but not into ${root}.`), elsewhere: latest };
85
89
  return { updated: true, from, to, reason: `Installed v${to} from npm.` };
86
90
  }
87
91
 
@@ -93,10 +97,27 @@ export async function applyUpdate({
93
97
  registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL,
94
98
  running = CURRENT_VERSION
95
99
  } = {}) {
96
- const r = fs.existsSync(path.join(root, '.git'))
97
- ? await updateCheckout(root, run, logger)
98
- : await updateNpmInstall(root, run, logger, registryUrl);
99
- // Newer code already on disk (a pull or an install outside the gateway) is an update for the
100
+ // npm writes into the global prefix, so a checkout is the one install it cannot serve: the running
101
+ // code is this directory, not the prefix. The registry is still asked, because the release on npm
102
+ // is the release every other install receives, but for a checkout a failed install is not the end
103
+ // of the update -- the checkout is the thing that can move, so it is asked next.
104
+ const checkout = fs.existsSync(path.join(root, '.git'));
105
+ let registry;
106
+ try {
107
+ registry = await updateNpmInstall(root, run, logger, registryUrl);
108
+ } catch (err) {
109
+ if (!checkout) throw err;
110
+ logger(`npm could not install it here: ${err.message}`);
111
+ registry = { ...noUpdate(readVersion(root), `npm could not install it here: ${err.message}`), elsewhere: true };
112
+ }
113
+ let r = registry;
114
+ if (!r.updated && checkout) {
115
+ r = await updateCheckout(root, run, logger);
116
+ // Neither could deliver it, and npm held a release this directory cannot take: both facts, because
117
+ // "already the latest version" on its own would hide the one version the reader does not have.
118
+ if (!r.updated && registry.elsewhere) r = { ...r, reason: `${registry.reason} ${r.reason}` };
119
+ }
120
+ // Newer code already on disk (an install outside the gateway) is an update for the
100
121
  // running process: without the restart it keeps the old code and the old version forever.
101
122
  const onDisk = readVersion(root);
102
123
  if (!r.updated && isNewer(onDisk, running)) {
package/windows.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // The real window of every model a profile serves.
2
+ //
3
+ // A coding tool sizes a session, and its auto-compact point, from the window its own catalog
4
+ // gives it. So the gateway reports what the model really has instead of a number someone picked
5
+ // in a checkbox. Two numbers come out of a provider's list:
6
+ //
7
+ // context the whole window the model accepts
8
+ // compact the window to compress at, which a Codex model publishes as `context_window`
9
+ // next to `max_context_window` (272K against 872K)
10
+ //
11
+ // A model whose list gives only one window has no compact window, and a provider that lists no
12
+ // limits at all leaves both unknown: the caller then falls back to its own template rather than
13
+ // inventing a number.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+
18
+ const ONE_HOUR_MS = 60 * 60 * 1000;
19
+ const DEFAULT_TTL_MS = ONE_HOUR_MS;
20
+ const FETCH_TIMEOUT_MS = 15000;
21
+
22
+ const n = v => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
23
+
24
+ // One entry of a provider's list -> the two windows, or null when it names none.
25
+ export function parseModelWindows(m) {
26
+ if (!m || typeof m !== 'object') return null;
27
+ const top = m.top_provider && typeof m.top_provider === 'object' ? m.top_provider : {};
28
+ const context = n(m.max_context_window) || n(m.context_length) || n(top.context_length) || n(m.context_window);
29
+ // intact serves the threshold under its own name; a Codex-shaped list carries it as the
30
+ // smaller of the two windows it publishes.
31
+ const compact = n(m.compact_window) || n(m.compaction_threshold)
32
+ || (context && n(m.context_window) && n(m.context_window) < context ? n(m.context_window) : 0);
33
+ if (!context && !compact) return null;
34
+ return { context, compact };
35
+ }
36
+
37
+ // The list as it names the models, so a caller can look one up by the id a profile maps.
38
+ export function parseModelList(data) {
39
+ const entries = Array.isArray(data?.data) ? data.data
40
+ : Array.isArray(data) ? data
41
+ : Array.isArray(data?.models) ? data.models
42
+ : [];
43
+ const out = {};
44
+ for (const m of entries) {
45
+ const id = String(typeof m === 'string' ? m : (m?.id || m?.name || '')).replace(/^models\//, '');
46
+ if (!id) continue;
47
+ const w = parseModelWindows(m);
48
+ if (w) out[id] = w;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Reads a provider's model list for its windows. Returns {} on any failure: a profile whose
55
+ * upstream cannot be reached keeps the last answer, never a wrong window.
56
+ */
57
+ export async function fetchProfileWindows(baseURL, apiKey, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
58
+ const base = String(baseURL || '').replace(/\/+$/, '');
59
+ if (!base) return {};
60
+ try {
61
+ const headers = { accept: 'application/json' };
62
+ if (apiKey) {
63
+ headers.authorization = `Bearer ${apiKey}`;
64
+ headers['x-api-key'] = apiKey;
65
+ }
66
+ const res = await fetch(`${base}/models`, { headers, signal: AbortSignal.timeout(timeoutMs) });
67
+ if (!res.ok) return {};
68
+ return parseModelList(await res.json());
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The windows of a profile's models, from the cache when it is fresh and from the profile's own
76
+ * list when it is not. A failed read keeps the stale answer, because an old window only sizes a
77
+ * session slightly wrong, while an empty one would hand the tool a template window it may not
78
+ * have. Never throws.
79
+ */
80
+ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
81
+ const cache = readWindowsCache(stateDir);
82
+ const entry = cache[cacheKey] || {};
83
+ const fresh = entry.at && now - entry.at < ttlMs;
84
+ if (fresh) return entry.models || {};
85
+ const models = await fetchProfileWindows(baseURL, apiKey);
86
+ if (Object.keys(models).length) {
87
+ cache[cacheKey] = { at: now, baseURL, models };
88
+ writeWindowsCache(stateDir, cache);
89
+ return models;
90
+ }
91
+ return entry.models || {};
92
+ }
93
+
94
+ export function windowsCachePath(stateDir) {
95
+ return path.join(stateDir, 'model-windows.json');
96
+ }
97
+
98
+ export function readWindowsCache(stateDir) {
99
+ try {
100
+ const d = JSON.parse(fs.readFileSync(windowsCachePath(stateDir), 'utf8'));
101
+ return d && typeof d === 'object' && !Array.isArray(d) ? d : {};
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ export function writeWindowsCache(stateDir, cache) {
108
+ const p = windowsCachePath(stateDir);
109
+ const tmp = `${p}.${process.pid}.tmp`;
110
+ try {
111
+ fs.writeFileSync(tmp, JSON.stringify(cache), 'utf8');
112
+ fs.renameSync(tmp, p);
113
+ return true;
114
+ } catch {
115
+ try { fs.rmSync(tmp, { force: true }); } catch {}
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The windows the Codex CLI itself cached for its signed-in account. It is the fallback for a
122
+ * profile that lists no limits: those numbers came from the provider the CLI uses, so they are
123
+ * better than the template and never a guess. Returns {} when the tool keeps no list.
124
+ */
125
+ export function readLocalCodexWindows(sources = {}) {
126
+ const codexHome = sources.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
127
+ try {
128
+ const d = JSON.parse(fs.readFileSync(path.join(codexHome, 'models_cache.json'), 'utf8'));
129
+ const models = Array.isArray(d?.models) ? d.models : [];
130
+ const out = {};
131
+ for (const m of models) {
132
+ const id = m && typeof m.slug === 'string' ? m.slug : '';
133
+ const context = n(m?.max_context_window) || n(m?.context_window);
134
+ const compact = context && n(m.context_window) < context ? n(m.context_window) : 0;
135
+ if (id && context) out[id] = { context, compact };
136
+ }
137
+ return out;
138
+ } catch {
139
+ return {};
140
+ }
141
+ }