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/CHANGELOG.md +27 -0
- package/README.md +27 -20
- package/README.vi.md +26 -16
- package/config.example.json +0 -11
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/docs/tool-vocabulary.md +236 -0
- package/formats.mjs +277 -55
- package/package.json +1 -1
- package/proxy.mjs +55 -19
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +23 -91
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +2 -2
- package/tests/dashboard.e2e.test.mjs +10 -13
- package/tests/formats.test.mjs +98 -1
- package/tests/gateway.e2e.test.mjs +23 -9
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/state.test.mjs +6 -7
- package/tests/tool-repair.test.mjs +280 -0
- package/tests/ui.test.mjs +22 -15
- package/tests/update.test.mjs +92 -9
- package/tests/windows.test.mjs +124 -0
- package/toolvocab.mjs +272 -0
- package/ui.html +32 -76
- package/update.mjs +29 -8
- package/windows.mjs +141 -0
package/update.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// Brings this install to the newest release
|
|
2
|
-
//
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
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
|
+
}
|