9router-manager 0.0.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/LICENSE +21 -0
- package/README.md +327 -0
- package/bat/check-results.bat +60 -0
- package/bat/run-scan.bat +65 -0
- package/bat/setup-scheduler.bat +75 -0
- package/bin/9router-manager.js +7 -0
- package/metadata.json +1536 -0
- package/package.json +65 -0
- package/sh/check-results.sh +54 -0
- package/sh/run-scan.sh +57 -0
- package/sh/setup-scheduler-macos.sh +109 -0
- package/sh/setup-scheduler.sh +114 -0
- package/src/cli.js +799 -0
- package/src/combo.js +165 -0
- package/src/env.js +126 -0
- package/src/metadata.js +137 -0
- package/src/password.js +142 -0
- package/src/results.js +134 -0
- package/src/scan.js +293 -0
package/src/scan.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// src/scan.js
|
|
2
|
+
// API + concurrent scan logic for 9Router Manager.
|
|
3
|
+
//
|
|
4
|
+
// Functions:
|
|
5
|
+
// - login(): POST /api/auth/login, populate cookie store
|
|
6
|
+
// - getModels(): GET /v1/models, return array of model IDs
|
|
7
|
+
// - testSingleModel(): test 1 model with retry/backoff, return result tuple
|
|
8
|
+
// - testModelConcurrent(): fan-out with progress bar (cli-progress)
|
|
9
|
+
// - buildAuditData(): assemble the audit JSON shape
|
|
10
|
+
// - writeAudit(): async file write
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { SingleBar, Presets } from 'cli-progress';
|
|
14
|
+
import { getAvailableProviders } from './combo.js';
|
|
15
|
+
import { _resolveDbPath, _getDefaultAuditPath } from './env.js';
|
|
16
|
+
|
|
17
|
+
const BASE_URL = process.env.NINEROUTER_BASE_URL || 'http://localhost:20128';
|
|
18
|
+
const TIMEOUT_MS = (parseInt(process.env.NINEROUTER_TIMEOUT, 10) || 8) * 1000;
|
|
19
|
+
const SLEEP_BETWEEN_TESTS_S = parseFloat(process.env.NINEROUTER_SLEEP) || 0.05;
|
|
20
|
+
const MAX_RETRIES = parseInt(process.env.NINEROUTER_RETRY_429_MAX, 10) || 2;
|
|
21
|
+
const RETRY_DELAY_S = parseFloat(process.env.NINEROUTER_RETRY_429_DELAY) || 3.0;
|
|
22
|
+
const TEST_PROMPT = process.env.NINEROUTER_PROMPT || '1+1=?';
|
|
23
|
+
const MAX_WORKERS = parseInt(process.env.NINEROUTER_MAX_WORKERS, 10) || 1;
|
|
24
|
+
|
|
25
|
+
// Simple in-memory cookie store (single-session scan)
|
|
26
|
+
const _cookies = new Map();
|
|
27
|
+
function _setCookieFromResponse(res) {
|
|
28
|
+
// Node fetch has getSetCookie() in 18.14+ / 20+
|
|
29
|
+
const setCookies = typeof res.headers.getSetCookie === 'function'
|
|
30
|
+
? res.headers.getSetCookie()
|
|
31
|
+
: (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
|
32
|
+
for (const sc of setCookies) {
|
|
33
|
+
const [pair] = sc.split(';');
|
|
34
|
+
const eq = pair.indexOf('=');
|
|
35
|
+
if (eq < 0) continue;
|
|
36
|
+
const name = pair.slice(0, eq).trim();
|
|
37
|
+
const value = pair.slice(eq + 1).trim();
|
|
38
|
+
if (name) _cookies.set(name, value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function _cookieHeader() {
|
|
42
|
+
if (_cookies.size === 0) return '';
|
|
43
|
+
return Array.from(_cookies.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolved password state — set by cli.js (after interactive prompt or --password flag)
|
|
47
|
+
// via setResolvedPassword(). Falls back to the env var so external callers (e.g.
|
|
48
|
+
// bin/9router-manager.js invoked directly) keep working without code changes.
|
|
49
|
+
let _resolvedPassword = null;
|
|
50
|
+
|
|
51
|
+
export function setResolvedPassword(pwd) {
|
|
52
|
+
_resolvedPassword = pwd;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _requirePassword() {
|
|
56
|
+
if (_resolvedPassword) return _resolvedPassword;
|
|
57
|
+
const envPwd = process.env.NINEROUTER_PASSWORD;
|
|
58
|
+
if (envPwd && envPwd !== '***ISI_PASSWORD_DISINI***') return envPwd;
|
|
59
|
+
console.error('[scan] NINEROUTER_PASSWORD tidak diset atau masih placeholder.');
|
|
60
|
+
throw new Error('NINEROUTER_PASSWORD required');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- public API ----
|
|
64
|
+
|
|
65
|
+
export async function login() {
|
|
66
|
+
const pwd = _requirePassword();
|
|
67
|
+
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ password: pwd }),
|
|
71
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
72
|
+
});
|
|
73
|
+
_setCookieFromResponse(res);
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
throw new Error(`Login gagal: ${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getModels() {
|
|
82
|
+
const res = await fetch(`${BASE_URL}/v1/models`, {
|
|
83
|
+
headers: { Cookie: _cookieHeader() },
|
|
84
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`GET /v1/models failed: ${res.status} ${res.statusText}`);
|
|
88
|
+
}
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
// /v1/models returns { data: [{ id, ... }] } (OpenAI-compatible)
|
|
91
|
+
if (Array.isArray(data?.data)) {
|
|
92
|
+
return data.data.map((m) => m.id).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(data)) {
|
|
95
|
+
return data.map((m) => (typeof m === 'string' ? m : m.id)).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
101
|
+
function _generateVerifyToken(length = 8) {
|
|
102
|
+
let s = '';
|
|
103
|
+
for (let i = 0; i < length; i++) s += CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Module-level verify context. When set by setVerifyContext(), testSingleModel
|
|
108
|
+
// uses this exact token+prompt for ALL models in the run — so the audit can
|
|
109
|
+
// record verify_token/verify_prompt matching the actual prompt sent.
|
|
110
|
+
// Falls back to per-call random if unset.
|
|
111
|
+
let _currentToken = null;
|
|
112
|
+
let _currentPrompt = null;
|
|
113
|
+
|
|
114
|
+
export function setVerifyContext(token, prompt) {
|
|
115
|
+
_currentToken = token;
|
|
116
|
+
_currentPrompt = prompt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function clearVerifyContext() {
|
|
120
|
+
_currentToken = null;
|
|
121
|
+
_currentPrompt = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// testSingleModel returns: { model, ok, detail, actual_model, elapsed_ms, elapsed_seconds }
|
|
125
|
+
export async function testSingleModel(model, opts = {}) {
|
|
126
|
+
const maxRetries = opts.maxRetries ?? MAX_RETRIES;
|
|
127
|
+
const retryDelayS = opts.retryDelayS ?? RETRY_DELAY_S;
|
|
128
|
+
// Honor explicit opts > module context > auto-generate fallback
|
|
129
|
+
const verifyToken = opts.verifyToken ?? _currentToken ?? _generateVerifyToken(8);
|
|
130
|
+
const prompt = opts.prompt ?? _currentPrompt ?? `${TEST_PROMPT} (echo: ${verifyToken})`;
|
|
131
|
+
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
let lastError = null;
|
|
134
|
+
|
|
135
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: {
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
Cookie: _cookieHeader(),
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
model,
|
|
145
|
+
messages: [{ role: 'user', content: prompt }],
|
|
146
|
+
stream: false,
|
|
147
|
+
}),
|
|
148
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (res.status === 429 && attempt < maxRetries) {
|
|
152
|
+
await new Promise((r) => setTimeout(r, retryDelayS * 1000));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
157
|
+
const elapsedMs = (Date.now() - start);
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
return {
|
|
162
|
+
model, ok: false,
|
|
163
|
+
detail: `HTTP ${res.status}: ${text.slice(0, 200)}`,
|
|
164
|
+
actual_model: null, elapsed_ms: elapsedMs, elapsed_seconds: elapsed,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const data = await res.json();
|
|
169
|
+
const actualModel = data?.model || model;
|
|
170
|
+
const content = data?.choices?.[0]?.message?.content || '';
|
|
171
|
+
const ok = content.includes(verifyToken);
|
|
172
|
+
return {
|
|
173
|
+
model, ok,
|
|
174
|
+
detail: ok ? 'token echoed' : `token missing: ${content.slice(0, 100)}`,
|
|
175
|
+
actual_model: actualModel, elapsed_ms: elapsedMs, elapsed_seconds: elapsed,
|
|
176
|
+
};
|
|
177
|
+
} catch (e) {
|
|
178
|
+
lastError = e;
|
|
179
|
+
if (attempt < maxRetries) {
|
|
180
|
+
await new Promise((r) => setTimeout(r, retryDelayS * 1000));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
187
|
+
return {
|
|
188
|
+
model, ok: false,
|
|
189
|
+
detail: lastError?.message || 'unknown error',
|
|
190
|
+
actual_model: null, elapsed_ms: Date.now() - start, elapsed_seconds: elapsed,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// testModelConcurrent: fan-out with workers, progress bar.
|
|
195
|
+
// Sleep between tests is intentionally skipped here — concurrent tests share the
|
|
196
|
+
// same network I/O budget and rate-limiting is handled via 429 retries instead.
|
|
197
|
+
export async function testModelConcurrent(models, opts = {}) {
|
|
198
|
+
const maxWorkers = opts.maxWorkers ?? MAX_WORKERS;
|
|
199
|
+
const onProgress = opts.onProgress || (() => {});
|
|
200
|
+
|
|
201
|
+
const results = new Array(models.length);
|
|
202
|
+
let cursor = 0;
|
|
203
|
+
let completed = 0;
|
|
204
|
+
const total = models.length;
|
|
205
|
+
|
|
206
|
+
async function worker() {
|
|
207
|
+
while (true) {
|
|
208
|
+
const idx = cursor++;
|
|
209
|
+
if (idx >= models.length) return;
|
|
210
|
+
const model = models[idx];
|
|
211
|
+
const r = await testSingleModel(model);
|
|
212
|
+
results[idx] = r;
|
|
213
|
+
completed++;
|
|
214
|
+
onProgress({ completed, total, current: model, result: r });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const workers = Array.from({ length: Math.min(maxWorkers, models.length || 1) }, () => worker());
|
|
219
|
+
await Promise.all(workers);
|
|
220
|
+
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Convenience: run with cli-progress bar
|
|
225
|
+
export async function testModelConcurrentWithBar(models, opts = {}) {
|
|
226
|
+
const bar = new SingleBar(
|
|
227
|
+
{ format: 'Testing |{bar}| {percentage}% | {value}/{total} | {model}', hideCursor: true },
|
|
228
|
+
Presets.shades_classic
|
|
229
|
+
);
|
|
230
|
+
bar.start(models.length, 0, { model: '...' });
|
|
231
|
+
const results = await testModelConcurrent(models, {
|
|
232
|
+
...opts,
|
|
233
|
+
onProgress: ({ completed, total, current }) => {
|
|
234
|
+
bar.update(completed, { model: current });
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
bar.stop();
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============================================================
|
|
242
|
+
// AUDIT
|
|
243
|
+
// ============================================================
|
|
244
|
+
const AUDIT_PATH = process.env.NINEROUTER_AUDIT_PATH
|
|
245
|
+
? _resolveDbPath(process.env.NINEROUTER_AUDIT_PATH)
|
|
246
|
+
: _getDefaultAuditPath();
|
|
247
|
+
|
|
248
|
+
export async function writeAudit(auditData) {
|
|
249
|
+
// Ensure parent dir exists (mkdirSync recursive)
|
|
250
|
+
await fs.mkdir(path.dirname(AUDIT_PATH), { recursive: true });
|
|
251
|
+
await fs.writeFile(AUDIT_PATH, JSON.stringify(auditData, null, 2), 'utf-8');
|
|
252
|
+
console.error(`[scan] audit written: ${AUDIT_PATH}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// buildAuditData assembles the audit JSON. Schema is pinned by tests/audit-shape.test.js:
|
|
256
|
+
// - top-level fields (see audit-shape.test.js REQUIRED_TOP_LEVEL)
|
|
257
|
+
// - counts coerced to strings
|
|
258
|
+
// - actual_model propagated for every result (including failed/skipped)
|
|
259
|
+
export function buildAuditData({
|
|
260
|
+
startedAt, finishedAt, durationSeconds,
|
|
261
|
+
verifyToken, verifyPrompt,
|
|
262
|
+
models, candidates, skipped,
|
|
263
|
+
okCount, failedCount,
|
|
264
|
+
oldModels, newModels,
|
|
265
|
+
results,
|
|
266
|
+
}) {
|
|
267
|
+
const added = newModels.filter((m) => !oldModels.includes(m));
|
|
268
|
+
const removed = oldModels.filter((m) => !newModels.includes(m));
|
|
269
|
+
return {
|
|
270
|
+
started_at: startedAt,
|
|
271
|
+
finished_at: finishedAt,
|
|
272
|
+
duration_seconds: parseFloat(durationSeconds.toFixed(2)),
|
|
273
|
+
total_wall_seconds: parseFloat(durationSeconds.toFixed(2)),
|
|
274
|
+
verify_token: verifyToken,
|
|
275
|
+
verify_prompt: verifyPrompt,
|
|
276
|
+
discovered_count: String(models.length),
|
|
277
|
+
candidate_count: String(candidates.length),
|
|
278
|
+
skipped_count: String(skipped),
|
|
279
|
+
ok_count: String(okCount),
|
|
280
|
+
failed_count: String(failedCount),
|
|
281
|
+
old_combo_count: String(oldModels.length),
|
|
282
|
+
new_combo_count: String(newModels.length),
|
|
283
|
+
added,
|
|
284
|
+
removed,
|
|
285
|
+
results: results.map((r) => ({
|
|
286
|
+
model: r.model,
|
|
287
|
+
ok: !!r.ok,
|
|
288
|
+
detail: r.detail || '',
|
|
289
|
+
elapsed_ms: r.elapsed_ms || 0,
|
|
290
|
+
actual_model: r.actual_model ?? r.model ?? null,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
293
|
+
}
|