virouter 0.2.4 → 0.2.5
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/README.md +7 -8
- package/bin/virouter.mjs +118 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,16 +17,15 @@ $env:VIROUTER_API_ORIGIN="http://127.0.0.1:8787"
|
|
|
17
17
|
npx virouter@latest
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
On the first install, the installer
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- `1` = Windows
|
|
24
|
-
- `2` = macOS
|
|
25
|
-
- `3` = Linux
|
|
20
|
+
On the first install, the installer detects the machine (Windows, macOS, Linux or WSL — it no
|
|
21
|
+
longer asks), checks the key against the gateway and shows it masked with its plan, credit and
|
|
22
|
+
expiry, asks for the default model, and configures:
|
|
26
23
|
|
|
27
24
|
- `ANTHROPIC_BASE_URL=https://virouter.click`
|
|
28
25
|
- `ANTHROPIC_AUTH_TOKEN`
|
|
29
26
|
- `model` in `~/.claude/settings.json` — `claude-fable-5-1` on a first install
|
|
27
|
+
- `env.ANTHROPIC_DEFAULT_MODEL` in the same file, so the `/model` picker's "Default" row resolves
|
|
28
|
+
to a VIROUTER model instead of Claude Code's own default (a choice saved with `/model` still wins)
|
|
30
29
|
- `ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5`
|
|
31
30
|
- `VIROUTER_API_KEY` for OpenCode
|
|
32
31
|
- `~/.config/opencode/opencode.json` with VIROUTER as the default provider
|
|
@@ -43,8 +42,8 @@ back to the install-time model. An install that pinned it is migrated — that m
|
|
|
43
42
|
`settings.json`. Only if `settings.json` is not valid JSON does the installer leave it untouched
|
|
44
43
|
and fall back to pinning `ANTHROPIC_MODEL`.
|
|
45
44
|
|
|
46
|
-
The
|
|
47
|
-
share
|
|
45
|
+
The key is visible while you type it; afterwards the installer only ever prints it masked
|
|
46
|
+
(`viro_AbCd…UvWx`). Do not record or share the terminal while typing it.
|
|
48
47
|
|
|
49
48
|
When an OpenCode configuration already exists, its enabled providers and credentials are
|
|
50
49
|
preserved; VIROUTER is appended and selected as the default provider. A new configuration
|
package/bin/virouter.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { arch, homedir, hostname, release } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { createInterface } from 'node:readline/promises';
|
|
8
|
-
import {
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
10
10
|
const API_ORIGIN = (process.env.VIROUTER_API_ORIGIN || 'https://virouter.click').replace(/\/$/, '');
|
|
11
11
|
const MARKER_START = '# >>> VIROUTER CLAUDE CODE CONFIG >>>';
|
|
@@ -42,30 +42,59 @@ For local testing, set VIROUTER_API_ORIGIN=http://127.0.0.1:8787.
|
|
|
42
42
|
It does not install a server or store a local model.`);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Colour only on a real terminal, and never when NO_COLOR is set (https://no-color.org).
|
|
46
|
+
const USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
47
|
+
const paint = (code) => (text) => (USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : String(text));
|
|
48
|
+
const style = {
|
|
49
|
+
bold: paint('1'),
|
|
50
|
+
dim: paint('2'),
|
|
51
|
+
red: paint('31'),
|
|
52
|
+
green: paint('32'),
|
|
53
|
+
yellow: paint('33'),
|
|
54
|
+
cyan: paint('36'),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Enough of the key to recognise it, never enough to use it. */
|
|
58
|
+
export function maskKey(key) {
|
|
59
|
+
return key.length > 16 ? `${key.slice(0, 9)}…${key.slice(-4)}` : `${key.slice(0, 5)}…`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The machine the installer is configuring. Detected rather than asked: picking the wrong
|
|
64
|
+
* number wrote a shell profile on Windows or `setx` on Linux, and neither failed loudly.
|
|
65
|
+
*/
|
|
66
|
+
export function detectMachine(platform = process.platform) {
|
|
67
|
+
if (platform === 'win32') return { os: '1', name: 'Windows' };
|
|
68
|
+
if (platform === 'darwin') return { os: '2', name: 'macOS' };
|
|
69
|
+
let wsl = false;
|
|
70
|
+
try {
|
|
71
|
+
wsl = /microsoft/i.test(readFileSync('/proc/version', 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
// Not Linux proper, or /proc is unavailable; plain Linux it is.
|
|
74
|
+
}
|
|
75
|
+
return { os: '3', name: wsl ? 'Linux (WSL)' : 'Linux' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function step(label, detail = '') {
|
|
79
|
+
console.log(`${style.green('✓')} ${label}${detail ? ` ${style.dim(detail)}` : ''}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
45
82
|
async function readVisibleInput(prompt) {
|
|
46
83
|
const line = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
47
84
|
const answer = await line.question(prompt);
|
|
48
85
|
line.close();
|
|
49
|
-
|
|
50
|
-
console.log(`\nĐã nhận customer key: ${value}`);
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function chooseOs(input) {
|
|
55
|
-
console.log('Hệ điều hành:');
|
|
56
|
-
console.log(' ● 1 = Windows');
|
|
57
|
-
console.log(' ● 2 = macOS');
|
|
58
|
-
console.log(' ● 3 = Linux');
|
|
59
|
-
const answer = (await input.question('Chọn số (1/2/3): ')).trim();
|
|
60
|
-
if (!['1', '2', '3'].includes(answer)) throw new Error('Chọn 1, 2 hoặc 3.');
|
|
61
|
-
return answer;
|
|
86
|
+
return answer.trim();
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
async function chooseModel(input, currentModel) {
|
|
65
|
-
console.log('
|
|
66
|
-
VIROUTER_MODELS.forEach((model, index) => console.log(` ${index + 1} = ${model}`));
|
|
90
|
+
console.log(`\n${style.bold('Model mặc định cho Claude Code')} ${style.dim('(đổi lại bất cứ lúc nào bằng /model)')}`);
|
|
67
91
|
const defaultIndex = Math.max(0, VIROUTER_MODELS.indexOf(currentModel));
|
|
68
|
-
|
|
92
|
+
VIROUTER_MODELS.forEach((model, index) => {
|
|
93
|
+
const isDefault = index === defaultIndex;
|
|
94
|
+
const number = style.cyan(String(index + 1).padStart(2));
|
|
95
|
+
console.log(` ${number} ${isDefault ? style.bold(model) : model}${isDefault ? style.green(' ← mặc định') : ''}`);
|
|
96
|
+
});
|
|
97
|
+
const answer = (await input.question(`Chọn số ${style.dim(`[${defaultIndex + 1}]`)}: `)).trim() || String(defaultIndex + 1);
|
|
69
98
|
const selected = VIROUTER_MODELS[Number(answer) - 1];
|
|
70
99
|
if (!selected) throw new Error('Lựa chọn model không hợp lệ.');
|
|
71
100
|
return selected;
|
|
@@ -125,14 +154,20 @@ export function resolveStartingModel(settingsModel, legacyModel) {
|
|
|
125
154
|
return null;
|
|
126
155
|
}
|
|
127
156
|
|
|
128
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Returns new settings with `model` set, any ANTHROPIC_MODEL pin removed from `env`, and the
|
|
159
|
+
* picker's "Default" row pointed at VIROUTER's default.
|
|
160
|
+
*
|
|
161
|
+
* `/model` always lists a "Default (recommended)" row that resolves to Claude Code's own
|
|
162
|
+
* runtime default — a model name this gateway may not serve. ANTHROPIC_DEFAULT_MODEL makes
|
|
163
|
+
* that row resolve to ours instead ("Set by ANTHROPIC_DEFAULT_MODEL"), and unlike
|
|
164
|
+
* ANTHROPIC_MODEL it does not override a choice the customer saved with /model.
|
|
165
|
+
*/
|
|
129
166
|
export function withRememberedModel(settings, model) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
return next;
|
|
167
|
+
const currentEnv = settings.env && typeof settings.env === 'object' ? settings.env : {};
|
|
168
|
+
const { ANTHROPIC_MODEL: _pinned, ...env } = currentEnv;
|
|
169
|
+
if (!VIROUTER_MODELS.includes(env.ANTHROPIC_DEFAULT_MODEL)) env.ANTHROPIC_DEFAULT_MODEL = DEFAULT_MODEL;
|
|
170
|
+
return { ...settings, model, env };
|
|
136
171
|
}
|
|
137
172
|
|
|
138
173
|
function writeClaudeSettings(path, settings) {
|
|
@@ -301,6 +336,7 @@ function configureUnix(os, apiKey, pinnedModel) {
|
|
|
301
336
|
return profiles.join(' and ');
|
|
302
337
|
}
|
|
303
338
|
|
|
339
|
+
/** `{ ok: true, me }`, `{ ok: false }` when the gateway rejects the key, or null if unreachable. */
|
|
304
340
|
async function validateKey(apiKey) {
|
|
305
341
|
const controller = new AbortController();
|
|
306
342
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
@@ -309,7 +345,8 @@ async function validateKey(apiKey) {
|
|
|
309
345
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
310
346
|
signal: controller.signal,
|
|
311
347
|
});
|
|
312
|
-
|
|
348
|
+
if (!response.ok) return { ok: false };
|
|
349
|
+
return { ok: true, me: await response.json().catch(() => null) };
|
|
313
350
|
} catch {
|
|
314
351
|
return null;
|
|
315
352
|
} finally {
|
|
@@ -317,6 +354,19 @@ async function validateKey(apiKey) {
|
|
|
317
354
|
}
|
|
318
355
|
}
|
|
319
356
|
|
|
357
|
+
function describeKey(me) {
|
|
358
|
+
if (!me || typeof me !== 'object') return '';
|
|
359
|
+
const parts = [];
|
|
360
|
+
if (me.planCode) parts.push(`gói ${me.planCode}`);
|
|
361
|
+
if (typeof me.creditsRemaining === 'number') {
|
|
362
|
+
parts.push(`còn ${me.creditsRemaining.toLocaleString('vi-VN')} credit`);
|
|
363
|
+
}
|
|
364
|
+
if (typeof me.expiresAt === 'number') {
|
|
365
|
+
parts.push(`hết hạn ${new Date(me.expiresAt * 1000).toLocaleDateString('vi-VN')}`);
|
|
366
|
+
}
|
|
367
|
+
return parts.join(' · ');
|
|
368
|
+
}
|
|
369
|
+
|
|
320
370
|
function launchClaude(apiKey, pinnedModel) {
|
|
321
371
|
const env = {
|
|
322
372
|
...process.env,
|
|
@@ -346,11 +396,13 @@ async function main() {
|
|
|
346
396
|
return;
|
|
347
397
|
}
|
|
348
398
|
|
|
349
|
-
|
|
399
|
+
const machine = detectMachine();
|
|
400
|
+
console.log(`\n${style.bold(style.cyan('VIROUTER'))} ${style.bold('· Claude Code installer')}`);
|
|
401
|
+
console.log(style.dim(`Gateway ${API_ORIGIN}`));
|
|
402
|
+
console.log(style.dim(`Máy ${machine.name} ${release()} · ${arch()} · ${hostname()}`));
|
|
403
|
+
console.log('');
|
|
350
404
|
const agentToolkit = detectAgentToolkit();
|
|
351
|
-
if (agentToolkit) {
|
|
352
|
-
console.log(`Phát hiện ${agentToolkit}. Giữ nguyên skills, rules và hooks hiện có.`);
|
|
353
|
-
}
|
|
405
|
+
if (agentToolkit) step(`Phát hiện ${agentToolkit}`, '— giữ nguyên skills, rules và hooks hiện có');
|
|
354
406
|
let input;
|
|
355
407
|
|
|
356
408
|
try {
|
|
@@ -358,13 +410,20 @@ async function main() {
|
|
|
358
410
|
const reuseEnvironmentKey = process.argv.includes('--reuse-key') || !process.stdin.isTTY;
|
|
359
411
|
const apiKey = reuseEnvironmentKey && process.env.VIROUTER_API_KEY
|
|
360
412
|
? process.env.VIROUTER_API_KEY
|
|
361
|
-
: await readVisibleInput('
|
|
413
|
+
: await readVisibleInput(`${style.bold('API key')} ${style.dim('(viro_…, hiển thị khi nhập)')}: `);
|
|
362
414
|
if (!/^viro_[A-Za-z0-9_-]{20,}$/.test(apiKey)) {
|
|
363
415
|
throw new Error('API key không hợp lệ. Key phải bắt đầu bằng viro_.');
|
|
364
416
|
}
|
|
365
417
|
|
|
418
|
+
const checked = await validateKey(apiKey);
|
|
419
|
+
if (checked && !checked.ok) {
|
|
420
|
+
throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
|
|
421
|
+
}
|
|
422
|
+
if (checked) step(`Key ${style.bold(maskKey(apiKey))} hợp lệ`, describeKey(checked.me));
|
|
423
|
+
else console.log(`${style.yellow('!')} Key ${maskKey(apiKey)} — không kiểm tra được lúc này, vẫn lưu cấu hình.`);
|
|
424
|
+
|
|
366
425
|
input = createInterface({ input: process.stdin, output: process.stdout });
|
|
367
|
-
const os =
|
|
426
|
+
const { os } = machine;
|
|
368
427
|
const keyChanged = previous.key !== null && previous.key !== apiKey;
|
|
369
428
|
const claudeSettings = readClaudeSettings();
|
|
370
429
|
// Re-running the installer keeps whatever the customer last picked with /model.
|
|
@@ -374,12 +433,7 @@ async function main() {
|
|
|
374
433
|
: await chooseModel(input, remembered ?? DEFAULT_MODEL);
|
|
375
434
|
input.close();
|
|
376
435
|
input = undefined;
|
|
377
|
-
|
|
378
|
-
const valid = await validateKey(apiKey);
|
|
379
|
-
if (valid === false) {
|
|
380
|
-
throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
|
|
381
|
-
}
|
|
382
|
-
if (valid === null) console.log('Không kiểm tra được API lúc này. Vẫn tiếp tục lưu cấu hình.');
|
|
436
|
+
console.log('');
|
|
383
437
|
|
|
384
438
|
// Remember the model in settings.json; pin it in the environment only if that file is not
|
|
385
439
|
// ours to rewrite (unparseable), since a session with no model at all would be worse.
|
|
@@ -388,30 +442,46 @@ async function main() {
|
|
|
388
442
|
writeClaudeSettings(claudeSettings.path, withRememberedModel(claudeSettings.settings, model));
|
|
389
443
|
} else {
|
|
390
444
|
pinnedModel = model;
|
|
391
|
-
console.log(
|
|
392
|
-
console.log('Model sẽ cố định qua biến môi trường; /model sẽ không được ghi nhớ giữa các phiên.');
|
|
445
|
+
console.log(`${style.yellow('!')} Không đọc được ${claudeSettings.path} (không phải JSON hợp lệ).`);
|
|
446
|
+
console.log(style.dim(' Model sẽ cố định qua biến môi trường; /model sẽ không được ghi nhớ giữa các phiên.'));
|
|
393
447
|
}
|
|
394
448
|
|
|
395
449
|
const location = os === '1'
|
|
396
450
|
? configureWindows(apiKey, pinnedModel)
|
|
397
451
|
: configureUnix(os, apiKey, pinnedModel);
|
|
398
452
|
const openCodeConfig = configureOpenCode(apiKey, model);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (openCodeConfig)
|
|
453
|
+
step('Claude Code', location);
|
|
454
|
+
step(`Model ${style.bold(model)}`, '— đổi bằng /model, lần mở sau giữ model đã chọn');
|
|
455
|
+
if (openCodeConfig) step('OpenCode', openCodeConfig);
|
|
402
456
|
if (process.argv.includes('--no-launch')) {
|
|
403
|
-
console.log(
|
|
457
|
+
console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
|
|
404
458
|
} else {
|
|
405
|
-
console.log('
|
|
459
|
+
console.log(`\n${style.cyan('→')} Khởi chạy Claude Code với VIROUTER…\n`);
|
|
406
460
|
launchClaude(apiKey, pinnedModel);
|
|
407
461
|
}
|
|
408
462
|
} catch (error) {
|
|
409
463
|
input?.close();
|
|
410
|
-
console.error(`\
|
|
464
|
+
console.error(`\n${style.red('Lỗi:')} ${error instanceof Error ? error.message : String(error)}`);
|
|
411
465
|
process.exitCode = 1;
|
|
412
466
|
}
|
|
413
467
|
}
|
|
414
468
|
|
|
415
|
-
|
|
469
|
+
/**
|
|
470
|
+
* True when this file is the program being run, not a module imported by a test.
|
|
471
|
+
*
|
|
472
|
+
* `npx` runs the package through a symlink (`node_modules/.bin/virouter`). Node reports that
|
|
473
|
+
* link in `argv[1]` but loads the real file, so comparing the two raw paths never matched on
|
|
474
|
+
* macOS and Linux: `npx virouter@latest` installed, printed nothing and exited.
|
|
475
|
+
*/
|
|
476
|
+
export function isMainModule(argv1 = process.argv[1], moduleUrl = import.meta.url) {
|
|
477
|
+
if (!argv1) return false;
|
|
478
|
+
try {
|
|
479
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (isMainModule()) {
|
|
416
486
|
await main();
|
|
417
487
|
}
|