nvicode 0.1.19 → 0.1.21
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 +21 -4
- package/dist/cli.js +359 -45
- package/dist/config.js +14 -3
- package/dist/models.js +270 -20
- package/dist/proxy.js +48 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
9
|
-
Route Claude Code, Codex CLI, and OpenClaw through NVIDIA or OpenRouter with one setup.
|
|
9
|
+
Route Claude Code, Codex CLI, Codex desktop app, and OpenClaw through NVIDIA or OpenRouter with one setup.
|
|
10
10
|
|
|
11
11
|
`nvicode` lets you choose a provider once, save the API key once, pick a model once, and then launch the coding tool you want against that same backend.
|
|
12
12
|
|
|
@@ -14,9 +14,12 @@ What it gives you:
|
|
|
14
14
|
- One guided setup flow for provider, key, and model
|
|
15
15
|
- Claude Code support
|
|
16
16
|
- Codex CLI support
|
|
17
|
+
- Codex desktop app support on macOS
|
|
17
18
|
- OpenClaw support
|
|
18
19
|
- NVIDIA proxy mode with pacing and local usage tracking
|
|
19
20
|
- OpenRouter direct mode for compatible models
|
|
21
|
+
- Dynamic NVIDIA model discovery for current Kimi, DeepSeek, GLM, and Qwen picks
|
|
22
|
+
- Dynamic OpenRouter free-model discovery for current Kimi, Qwen, GPT-OSS, Nemotron, Gemma, and Llama picks
|
|
20
23
|
|
|
21
24
|
Supported environments:
|
|
22
25
|
- macOS
|
|
@@ -43,17 +46,19 @@ Launch the tool you want:
|
|
|
43
46
|
```sh
|
|
44
47
|
nvicode launch claude
|
|
45
48
|
nvicode launch codex
|
|
49
|
+
nvicode launch codex-app
|
|
46
50
|
nvicode launch openclaw
|
|
47
51
|
```
|
|
48
52
|
|
|
49
53
|
Provider setup:
|
|
50
54
|
|
|
51
55
|
- NVIDIA: get a free key from [NVIDIA Build API Keys](https://build.nvidia.com/settings/api-keys)
|
|
52
|
-
- OpenRouter: use your OpenRouter API key
|
|
56
|
+
- OpenRouter: use your OpenRouter API key and pick from current free models
|
|
53
57
|
|
|
54
58
|
What happens after first launch:
|
|
55
59
|
- The first successful `nvicode launch claude` installs persistent plain `claude` routing.
|
|
56
60
|
- The first successful `nvicode launch codex` installs persistent plain `codex` routing.
|
|
61
|
+
- `nvicode launch codex-app` configures and opens the Codex desktop app.
|
|
57
62
|
- `nvicode launch openclaw` updates the default OpenClaw profile for the selected provider/model.
|
|
58
63
|
|
|
59
64
|
After that, plain:
|
|
@@ -87,6 +92,10 @@ continues using your selected `nvicode` provider and model.
|
|
|
87
92
|
- Codex CLI:
|
|
88
93
|
- uses the local `nvicode` proxy
|
|
89
94
|
- `nvicode` configures Codex to talk to that proxy through the Responses API
|
|
95
|
+
- Codex desktop app:
|
|
96
|
+
- uses the same local `nvicode` proxy through Codex user-level `config.toml`
|
|
97
|
+
- uses a managed authentication command that starts the proxy when needed
|
|
98
|
+
- preserves a one-time `config.toml.nvicode.bak` backup before the first config change
|
|
90
99
|
- OpenClaw:
|
|
91
100
|
- updates the default OpenClaw config for the selected provider/model
|
|
92
101
|
- restart the gateway after config changes:
|
|
@@ -103,6 +112,8 @@ Common commands:
|
|
|
103
112
|
nvicode select model
|
|
104
113
|
nvicode launch claude
|
|
105
114
|
nvicode launch codex
|
|
115
|
+
nvicode configure codex-app
|
|
116
|
+
nvicode launch codex-app
|
|
106
117
|
nvicode launch openclaw
|
|
107
118
|
nvicode dashboard
|
|
108
119
|
nvicode usage
|
|
@@ -116,8 +127,13 @@ nvicode launch codex "Explain this project"
|
|
|
116
127
|
|
|
117
128
|
Behavior notes:
|
|
118
129
|
- `nvicode select model` asks for provider, optional API key update, and model choice in one guided flow.
|
|
130
|
+
- For NVIDIA, model selection fetches the live NVIDIA catalog and highlights one current top pick each from Kimi, DeepSeek, GLM, and Qwen before falling back to curated defaults.
|
|
131
|
+
- For OpenRouter, model selection fetches the live OpenRouter catalog and shows top free endpoints first. If no free DeepSeek or GLM endpoint exists, they are not shown as free.
|
|
132
|
+
- On launch, `nvicode` replaces a retired saved NVIDIA model with the current recommended catalog pick.
|
|
119
133
|
- Claude Code uses direct OpenRouter mode for OpenRouter, and proxy mode for NVIDIA.
|
|
120
|
-
- Codex
|
|
134
|
+
- Codex CLI and the Codex desktop app use the local `nvicode` proxy path.
|
|
135
|
+
- `nvicode configure codex-app` updates the user-level Codex `config.toml` model and provider while preserving unrelated Codex settings.
|
|
136
|
+
- `nvicode launch codex-app` currently targets the macOS Codex desktop app.
|
|
121
137
|
- `nvicode usage`, `activity`, and `dashboard` are currently focused on NVIDIA proxy sessions.
|
|
122
138
|
- OpenRouter does not currently produce the same local usage visibility as the NVIDIA proxy flow.
|
|
123
139
|
- NVIDIA requests are paced to `40 RPM` by default. Override with `NVICODE_MAX_RPM` if your account allows more.
|
|
@@ -131,6 +147,7 @@ If your NVIDIA endpoint is not free, override local cost estimates with `NVICODE
|
|
|
131
147
|
|
|
132
148
|
- Claude Code must already be installed to use `nvicode launch claude`.
|
|
133
149
|
- Codex must already be installed to use `nvicode launch codex`. Install with `npm install -g @openai/codex`.
|
|
150
|
+
- Codex.app must already be installed on macOS to use `nvicode launch codex-app`.
|
|
134
151
|
- OpenClaw must already be installed to use `nvicode launch openclaw`. Install with `npm install -g openclaw@latest`.
|
|
135
152
|
- Node.js 20 or newer is required to install `nvicode`.
|
|
136
153
|
- OpenClaw itself requires Node.js `>=22.14.0`.
|
|
@@ -151,4 +168,4 @@ npm link
|
|
|
151
168
|
- `thinking` is disabled by default because some NVIDIA reasoning models can consume the entire output budget and return no visible answer to Claude Code.
|
|
152
169
|
- The proxy supports basic text, tool calls, tool results, and token count estimation.
|
|
153
170
|
- The proxy includes upstream request pacing and retries on NVIDIA `429` responses.
|
|
154
|
-
- Claude Code, Codex CLI, and OpenClaw remain the frontends; the selected provider/model becomes the backend.
|
|
171
|
+
- Claude Code, Codex CLI, Codex desktop app, and OpenClaw remain the frontends; the selected provider/model becomes the backend.
|
package/dist/cli.js
CHANGED
|
@@ -10,10 +10,12 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { getActiveApiKey, getActiveModel, getNvicodePaths, loadConfig, saveConfig, } from "./config.js";
|
|
12
12
|
import { createProxyServer } from "./proxy.js";
|
|
13
|
-
import { getRecommendedModels } from "./models.js";
|
|
13
|
+
import { fetchAvailableModelIds, getRecommendedModels, getRecommendedNvidiaModels, } from "./models.js";
|
|
14
14
|
import { filterRecordsSince, formatDuration, formatInteger, formatTimestamp, formatUsd, readUsageRecords, summarizeUsage, } from "./usage.js";
|
|
15
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
16
|
const NVICODE_WRAPPER_MARKER = "managed by nvicode";
|
|
17
|
+
const CODEX_APP_CONFIG_START = "# >>> nvicode codex app provider >>>";
|
|
18
|
+
const CODEX_APP_CONFIG_END = "# <<< nvicode codex app provider <<<";
|
|
17
19
|
const usage = () => {
|
|
18
20
|
console.log(`nvicode
|
|
19
21
|
|
|
@@ -28,6 +30,8 @@ Commands:
|
|
|
28
30
|
nvicode launch claude [...] Launch Claude Code through nvicode
|
|
29
31
|
nvicode launch openclaw [...] Launch OpenClaw through nvicode
|
|
30
32
|
nvicode launch codex [...] Launch Codex through nvicode
|
|
33
|
+
nvicode configure codex-app Configure the Codex desktop app
|
|
34
|
+
nvicode launch codex-app Configure and open the Codex desktop app
|
|
31
35
|
nvicode serve Run the local proxy in the foreground
|
|
32
36
|
`);
|
|
33
37
|
};
|
|
@@ -75,8 +79,23 @@ const readIfExists = async (targetPath) => {
|
|
|
75
79
|
return null;
|
|
76
80
|
}
|
|
77
81
|
};
|
|
82
|
+
const readTextPrefixIfExists = async (targetPath, byteLength = 4096) => {
|
|
83
|
+
let handle = null;
|
|
84
|
+
try {
|
|
85
|
+
handle = await fs.open(targetPath, "r");
|
|
86
|
+
const buffer = Buffer.alloc(byteLength);
|
|
87
|
+
const { bytesRead } = await handle.read(buffer, 0, byteLength, 0);
|
|
88
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await handle?.close().catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
78
97
|
const isManagedClaudeWrapper = async (targetPath) => {
|
|
79
|
-
const contents = await
|
|
98
|
+
const contents = await readTextPrefixIfExists(targetPath);
|
|
80
99
|
return contents?.includes(NVICODE_WRAPPER_MARKER) ?? false;
|
|
81
100
|
};
|
|
82
101
|
const renderClaudeWrapper = () => {
|
|
@@ -207,10 +226,29 @@ const ensureConfigured = async () => {
|
|
|
207
226
|
...config,
|
|
208
227
|
...(config.provider === "openrouter"
|
|
209
228
|
? { openrouterModel: first?.id || "anthropic/claude-sonnet-4.6" }
|
|
210
|
-
: { nvidiaModel: first?.id || "moonshotai/kimi-k2.
|
|
229
|
+
: { nvidiaModel: first?.id || "moonshotai/kimi-k2.6" }),
|
|
211
230
|
};
|
|
212
231
|
changed = true;
|
|
213
232
|
}
|
|
233
|
+
if (config.provider === "nvidia" && config.nvidiaApiKey && config.nvidiaModel) {
|
|
234
|
+
try {
|
|
235
|
+
const available = await fetchAvailableModelIds(config.nvidiaApiKey);
|
|
236
|
+
if (!available.has(config.nvidiaModel)) {
|
|
237
|
+
const [first] = getRecommendedNvidiaModels(available);
|
|
238
|
+
if (first && first.id !== config.nvidiaModel) {
|
|
239
|
+
console.error(`Saved NVIDIA model ${config.nvidiaModel} is no longer available. Switching to ${first.id}.`);
|
|
240
|
+
config = {
|
|
241
|
+
...config,
|
|
242
|
+
nvidiaModel: first.id,
|
|
243
|
+
};
|
|
244
|
+
changed = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Keep the saved model when the NVIDIA catalog is temporarily unavailable.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
214
252
|
if (changed) {
|
|
215
253
|
config = await saveConfig(config);
|
|
216
254
|
}
|
|
@@ -237,6 +275,9 @@ const runAuth = async () => {
|
|
|
237
275
|
};
|
|
238
276
|
const printModels = async (provider, apiKey) => {
|
|
239
277
|
const models = await getRecommendedModels(provider, apiKey || "");
|
|
278
|
+
printModelOptions(models);
|
|
279
|
+
};
|
|
280
|
+
const printModelOptions = (models) => {
|
|
240
281
|
models.forEach((model, index) => {
|
|
241
282
|
console.log(`${index + 1}. ${model.label}`);
|
|
242
283
|
console.log(` ${model.id}`);
|
|
@@ -254,10 +295,12 @@ const runSelectModel = async () => {
|
|
|
254
295
|
provider,
|
|
255
296
|
});
|
|
256
297
|
const models = await getRecommendedModels(provider, getActiveApiKey(nextConfig));
|
|
257
|
-
console.log(
|
|
258
|
-
|
|
298
|
+
console.log(provider === "openrouter"
|
|
299
|
+
? "Top free OpenRouter models:"
|
|
300
|
+
: "Top popular NVIDIA models:");
|
|
301
|
+
printModelOptions(models);
|
|
259
302
|
console.log("Or paste a full model id.");
|
|
260
|
-
console.log("Example: qwen/qwen3
|
|
303
|
+
console.log("Example: qwen/qwen3-coder:free");
|
|
261
304
|
const answer = await question("Model selection: ");
|
|
262
305
|
const index = Number(answer);
|
|
263
306
|
const chosenModel = Number.isInteger(index) && index >= 1 && index <= models.length
|
|
@@ -448,11 +491,19 @@ const runDashboard = async () => {
|
|
|
448
491
|
console.log("");
|
|
449
492
|
await runActivity();
|
|
450
493
|
};
|
|
451
|
-
const
|
|
494
|
+
const PROXY_PROTOCOL_VERSION = 2;
|
|
495
|
+
const isExpectedProxyHealth = (config, health) => health.ok === true &&
|
|
496
|
+
health.proxyProtocolVersion === PROXY_PROTOCOL_VERSION &&
|
|
497
|
+
health.provider === config.provider &&
|
|
498
|
+
health.model === getActiveModel(config) &&
|
|
499
|
+
health.port === config.proxyPort &&
|
|
500
|
+
health.thinking === config.thinking &&
|
|
501
|
+
health.maxRequestsPerMinute === config.maxRequestsPerMinute;
|
|
502
|
+
const waitForHealthyProxy = async (config) => {
|
|
452
503
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
453
504
|
try {
|
|
454
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
455
|
-
if (response.ok) {
|
|
505
|
+
const response = await fetch(`http://127.0.0.1:${config.proxyPort}/health`);
|
|
506
|
+
if (response.ok && isExpectedProxyHealth(config, await response.json())) {
|
|
456
507
|
return true;
|
|
457
508
|
}
|
|
458
509
|
}
|
|
@@ -463,11 +514,35 @@ const waitForHealthyProxy = async (port) => {
|
|
|
463
514
|
}
|
|
464
515
|
return false;
|
|
465
516
|
};
|
|
517
|
+
const stopExistingProxy = async () => {
|
|
518
|
+
const paths = getNvicodePaths();
|
|
519
|
+
const rawPid = await readIfExists(paths.pidFile);
|
|
520
|
+
const pid = Number(rawPid?.trim());
|
|
521
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
process.kill(pid, "SIGTERM");
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
531
|
+
try {
|
|
532
|
+
process.kill(pid, 0);
|
|
533
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
};
|
|
466
540
|
const ensureProxyRunning = async (config) => {
|
|
467
|
-
if (await waitForHealthyProxy(config
|
|
541
|
+
if (await waitForHealthyProxy(config)) {
|
|
468
542
|
return;
|
|
469
543
|
}
|
|
470
544
|
const paths = getNvicodePaths();
|
|
545
|
+
await stopExistingProxy();
|
|
471
546
|
await fs.mkdir(paths.stateDir, { recursive: true });
|
|
472
547
|
const logFd = openSync(paths.logFile, "a");
|
|
473
548
|
const child = spawn(process.execPath, [__filename, "serve"], {
|
|
@@ -480,7 +555,7 @@ const ensureProxyRunning = async (config) => {
|
|
|
480
555
|
});
|
|
481
556
|
child.unref();
|
|
482
557
|
await fs.writeFile(paths.pidFile, `${child.pid}\n`);
|
|
483
|
-
if (!(await waitForHealthyProxy(config
|
|
558
|
+
if (!(await waitForHealthyProxy(config))) {
|
|
484
559
|
throw new Error(`nvicode proxy failed to start. See ${paths.logFile}`);
|
|
485
560
|
}
|
|
486
561
|
};
|
|
@@ -536,6 +611,89 @@ const resolvePersistentClaudeCommand = async () => {
|
|
|
536
611
|
}
|
|
537
612
|
return null;
|
|
538
613
|
};
|
|
614
|
+
const normalizePathForComparison = async (targetPath) => {
|
|
615
|
+
const resolved = path.resolve(targetPath);
|
|
616
|
+
try {
|
|
617
|
+
const real = await fs.realpath(resolved);
|
|
618
|
+
return isWindows ? real.toLowerCase() : real;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return isWindows ? resolved.toLowerCase() : resolved;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const getPathDirectoryIndex = async (directory) => {
|
|
625
|
+
const target = await normalizePathForComparison(directory);
|
|
626
|
+
const pathEntries = (process.env.PATH || "").split(path.delimiter);
|
|
627
|
+
for (let index = 0; index < pathEntries.length; index += 1) {
|
|
628
|
+
const entry = pathEntries[index];
|
|
629
|
+
if (!entry) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if ((await normalizePathForComparison(entry)) === target) {
|
|
633
|
+
return index;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return -1;
|
|
637
|
+
};
|
|
638
|
+
const resolveClaudeShadowWrapperPath = async (claudeCommandPath) => {
|
|
639
|
+
const nvicodeCommandPath = await findExecutableInPath("nvicode");
|
|
640
|
+
if (!nvicodeCommandPath) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const nvicodeDirectory = path.dirname(nvicodeCommandPath);
|
|
644
|
+
const claudeDirectory = path.dirname(claudeCommandPath);
|
|
645
|
+
if ((await normalizePathForComparison(nvicodeDirectory)) ===
|
|
646
|
+
(await normalizePathForComparison(claudeDirectory))) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const nvicodeIndex = await getPathDirectoryIndex(nvicodeDirectory);
|
|
650
|
+
const claudeIndex = await getPathDirectoryIndex(claudeDirectory);
|
|
651
|
+
if (nvicodeIndex < 0 || claudeIndex < 0 || nvicodeIndex > claudeIndex) {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
for (const candidate of buildExecutableCandidates(nvicodeDirectory, "claude")) {
|
|
655
|
+
if ((await pathExists(candidate)) && !(await isManagedClaudeWrapper(candidate))) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return path.join(nvicodeDirectory, isWindows ? "claude.cmd" : "claude");
|
|
660
|
+
};
|
|
661
|
+
const mergeRoutingStatus = (left, right) => {
|
|
662
|
+
if (left === "installed" || right === "installed") {
|
|
663
|
+
return "installed";
|
|
664
|
+
}
|
|
665
|
+
if (left === "updated" || right === "updated") {
|
|
666
|
+
return "updated";
|
|
667
|
+
}
|
|
668
|
+
if (left === "already" || right === "already") {
|
|
669
|
+
return "already";
|
|
670
|
+
}
|
|
671
|
+
return "skipped";
|
|
672
|
+
};
|
|
673
|
+
const resolveLatestClaudeManagedVersion = async () => {
|
|
674
|
+
const versionsDir = path.join(os.homedir(), ".local", "share", "claude", "versions");
|
|
675
|
+
try {
|
|
676
|
+
const entries = await fs.readdir(versionsDir);
|
|
677
|
+
const sortedEntries = entries.sort((left, right) => left.localeCompare(right, undefined, {
|
|
678
|
+
numeric: true,
|
|
679
|
+
sensitivity: "base",
|
|
680
|
+
}));
|
|
681
|
+
for (let index = sortedEntries.length - 1; index >= 0; index -= 1) {
|
|
682
|
+
const entry = sortedEntries[index];
|
|
683
|
+
if (!entry) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const resolved = await resolveClaudeVersionEntry(path.join(versionsDir, entry));
|
|
687
|
+
if (resolved) {
|
|
688
|
+
return resolved;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// continue with other install layouts
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
};
|
|
539
697
|
const getWrapperInstallPaths = async (claudeCommandPath) => {
|
|
540
698
|
const directory = path.dirname(claudeCommandPath);
|
|
541
699
|
const existingNative = await findExistingClaudeNativeInDirectory(directory);
|
|
@@ -564,6 +722,101 @@ const writeExecutableTextFile = async (targetPath, contents) => {
|
|
|
564
722
|
await fs.chmod(targetPath, 0o755);
|
|
565
723
|
}
|
|
566
724
|
};
|
|
725
|
+
const getCodexConfigFile = () => path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
|
|
726
|
+
const renderTomlString = (value) => JSON.stringify(value);
|
|
727
|
+
const renderTomlStringArray = (values) => `[${values.map(renderTomlString).join(", ")}]`;
|
|
728
|
+
const stripManagedCodexAppProvider = (contents) => {
|
|
729
|
+
let remaining = contents;
|
|
730
|
+
while (remaining.includes(CODEX_APP_CONFIG_START)) {
|
|
731
|
+
const start = remaining.indexOf(CODEX_APP_CONFIG_START);
|
|
732
|
+
const end = remaining.indexOf(CODEX_APP_CONFIG_END, start);
|
|
733
|
+
if (end < 0) {
|
|
734
|
+
throw new Error(`Unable to update Codex app config: missing ${CODEX_APP_CONFIG_END}`);
|
|
735
|
+
}
|
|
736
|
+
remaining = [
|
|
737
|
+
remaining.slice(0, start).trimEnd(),
|
|
738
|
+
remaining.slice(end + CODEX_APP_CONFIG_END.length).trimStart(),
|
|
739
|
+
]
|
|
740
|
+
.filter(Boolean)
|
|
741
|
+
.join("\n");
|
|
742
|
+
}
|
|
743
|
+
return remaining;
|
|
744
|
+
};
|
|
745
|
+
const upsertRootTomlSetting = (contents, key, value) => {
|
|
746
|
+
const lines = contents.split("\n");
|
|
747
|
+
const firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
748
|
+
const rootEnd = firstTableIndex < 0 ? lines.length : firstTableIndex;
|
|
749
|
+
const settingPattern = new RegExp(`^\\s*${key}\\s*=`);
|
|
750
|
+
for (let index = 0; index < rootEnd; index += 1) {
|
|
751
|
+
if (settingPattern.test(lines[index] || "")) {
|
|
752
|
+
lines[index] = `${key} = ${value}`;
|
|
753
|
+
return lines.join("\n");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const modelIndex = lines.findIndex((line, index) => index < rootEnd && /^\s*model\s*=/.test(line));
|
|
757
|
+
lines.splice(modelIndex >= 0 ? modelIndex + 1 : 0, 0, `${key} = ${value}`);
|
|
758
|
+
return lines.join("\n");
|
|
759
|
+
};
|
|
760
|
+
const renderManagedCodexAppProvider = (config) => {
|
|
761
|
+
const proxyBaseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
762
|
+
return [
|
|
763
|
+
CODEX_APP_CONFIG_START,
|
|
764
|
+
"[model_providers.nvicode]",
|
|
765
|
+
'name = "nvicode proxy"',
|
|
766
|
+
`base_url = ${renderTomlString(proxyBaseUrl)}`,
|
|
767
|
+
'wire_api = "responses"',
|
|
768
|
+
"supports_websockets = false",
|
|
769
|
+
"",
|
|
770
|
+
"[model_providers.nvicode.auth]",
|
|
771
|
+
`command = ${renderTomlString(process.execPath)}`,
|
|
772
|
+
`args = ${renderTomlStringArray([__filename, "codex-app-token"])}`,
|
|
773
|
+
"timeout_ms = 15000",
|
|
774
|
+
CODEX_APP_CONFIG_END,
|
|
775
|
+
].join("\n");
|
|
776
|
+
};
|
|
777
|
+
const ensureCodexAppConfigured = async (config) => {
|
|
778
|
+
const configFile = getCodexConfigFile();
|
|
779
|
+
const existing = (await readIfExists(configFile)) || "";
|
|
780
|
+
let next = stripManagedCodexAppProvider(existing);
|
|
781
|
+
if (/^\s*\[model_providers\.nvicode(?:\.auth)?\]\s*$/m.test(next)) {
|
|
782
|
+
throw new Error(`Unable to configure Codex app: ${configFile} already contains an unmanaged [model_providers.nvicode] block.`);
|
|
783
|
+
}
|
|
784
|
+
next = upsertRootTomlSetting(next, "model", renderTomlString(getActiveModel(config)));
|
|
785
|
+
next = upsertRootTomlSetting(next, "model_provider", '"nvicode"');
|
|
786
|
+
next = `${next.trimEnd()}\n\n${renderManagedCodexAppProvider(config)}\n`;
|
|
787
|
+
if (next === existing) {
|
|
788
|
+
return { configFile, updated: false };
|
|
789
|
+
}
|
|
790
|
+
await fs.mkdir(path.dirname(configFile), { recursive: true });
|
|
791
|
+
if (await pathExists(configFile)) {
|
|
792
|
+
await fs.copyFile(configFile, `${configFile}.nvicode.bak`, constants.COPYFILE_EXCL).catch((error) => {
|
|
793
|
+
if (error.code !== "EEXIST") {
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
await fs.writeFile(configFile, next, "utf8");
|
|
799
|
+
return { configFile, updated: true };
|
|
800
|
+
};
|
|
801
|
+
const ensureClaudeShadowRouting = async (claudeCommandPath, wrapperContents) => {
|
|
802
|
+
const shadowWrapperPath = await resolveClaudeShadowWrapperPath(claudeCommandPath);
|
|
803
|
+
if (!shadowWrapperPath) {
|
|
804
|
+
return "skipped";
|
|
805
|
+
}
|
|
806
|
+
if (await isManagedClaudeWrapper(shadowWrapperPath)) {
|
|
807
|
+
const currentWrapper = await readIfExists(shadowWrapperPath);
|
|
808
|
+
if (currentWrapper === wrapperContents) {
|
|
809
|
+
return "already";
|
|
810
|
+
}
|
|
811
|
+
await writeExecutableTextFile(shadowWrapperPath, wrapperContents);
|
|
812
|
+
return "updated";
|
|
813
|
+
}
|
|
814
|
+
if (await pathExists(shadowWrapperPath)) {
|
|
815
|
+
return "skipped";
|
|
816
|
+
}
|
|
817
|
+
await writeExecutableTextFile(shadowWrapperPath, wrapperContents);
|
|
818
|
+
return "installed";
|
|
819
|
+
};
|
|
567
820
|
const ensurePersistentClaudeRouting = async () => {
|
|
568
821
|
const claudeCommandPath = await resolvePersistentClaudeCommand();
|
|
569
822
|
if (!claudeCommandPath) {
|
|
@@ -571,25 +824,31 @@ const ensurePersistentClaudeRouting = async () => {
|
|
|
571
824
|
}
|
|
572
825
|
const wrapperContents = renderClaudeWrapper();
|
|
573
826
|
const { wrapperPath, nativePath } = await getWrapperInstallPaths(claudeCommandPath);
|
|
827
|
+
let routingStatus = "skipped";
|
|
574
828
|
if (await isManagedClaudeWrapper(wrapperPath)) {
|
|
575
829
|
const currentWrapper = await readIfExists(wrapperPath);
|
|
576
830
|
if (currentWrapper === wrapperContents) {
|
|
577
|
-
|
|
831
|
+
routingStatus = "already";
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
await writeExecutableTextFile(wrapperPath, wrapperContents);
|
|
835
|
+
routingStatus = "updated";
|
|
578
836
|
}
|
|
579
|
-
await writeExecutableTextFile(wrapperPath, wrapperContents);
|
|
580
|
-
return "updated";
|
|
581
|
-
}
|
|
582
|
-
if (!(await pathExists(nativePath))) {
|
|
583
|
-
await fs.rename(claudeCommandPath, nativePath);
|
|
584
|
-
}
|
|
585
|
-
else if (claudeCommandPath !== wrapperPath && await pathExists(wrapperPath)) {
|
|
586
|
-
await fs.rm(wrapperPath, { force: true });
|
|
587
837
|
}
|
|
588
|
-
else
|
|
589
|
-
await
|
|
838
|
+
else {
|
|
839
|
+
if (!(await pathExists(nativePath))) {
|
|
840
|
+
await fs.rename(claudeCommandPath, nativePath);
|
|
841
|
+
}
|
|
842
|
+
else if (claudeCommandPath !== wrapperPath && await pathExists(wrapperPath)) {
|
|
843
|
+
await fs.rm(wrapperPath, { force: true });
|
|
844
|
+
}
|
|
845
|
+
else if (claudeCommandPath === wrapperPath) {
|
|
846
|
+
await fs.rm(wrapperPath, { force: true });
|
|
847
|
+
}
|
|
848
|
+
await writeExecutableTextFile(wrapperPath, wrapperContents);
|
|
849
|
+
routingStatus = "installed";
|
|
590
850
|
}
|
|
591
|
-
await
|
|
592
|
-
return "installed";
|
|
851
|
+
return mergeRoutingStatus(routingStatus, await ensureClaudeShadowRouting(claudeCommandPath, wrapperContents));
|
|
593
852
|
};
|
|
594
853
|
const findExistingCodexNativeInDirectory = async (directory) => {
|
|
595
854
|
for (const name of getCodexNativeNames()) {
|
|
@@ -656,6 +915,10 @@ const ensurePersistentCodexRouting = async () => {
|
|
|
656
915
|
return "installed";
|
|
657
916
|
};
|
|
658
917
|
const resolveClaudeBinary = async () => {
|
|
918
|
+
const latestManagedVersion = await resolveLatestClaudeManagedVersion();
|
|
919
|
+
if (latestManagedVersion) {
|
|
920
|
+
return latestManagedVersion;
|
|
921
|
+
}
|
|
659
922
|
for (const name of getClaudeNativeNames()) {
|
|
660
923
|
const nativeInPath = await findExecutableInPath(name);
|
|
661
924
|
if (nativeInPath) {
|
|
@@ -674,27 +937,10 @@ const resolveClaudeBinary = async () => {
|
|
|
674
937
|
path.join(os.homedir(), ".local", "bin", "claude"),
|
|
675
938
|
];
|
|
676
939
|
for (const candidate of homeBinCandidates) {
|
|
677
|
-
if (await isExecutable(candidate)) {
|
|
940
|
+
if ((await isExecutable(candidate)) && !(await isManagedClaudeWrapper(candidate))) {
|
|
678
941
|
return candidate;
|
|
679
942
|
}
|
|
680
943
|
}
|
|
681
|
-
const versionsDir = path.join(os.homedir(), ".local", "share", "claude", "versions");
|
|
682
|
-
try {
|
|
683
|
-
const entries = await fs.readdir(versionsDir);
|
|
684
|
-
const latest = entries.sort((left, right) => left.localeCompare(right, undefined, {
|
|
685
|
-
numeric: true,
|
|
686
|
-
sensitivity: "base",
|
|
687
|
-
})).at(-1);
|
|
688
|
-
if (latest) {
|
|
689
|
-
const resolved = await resolveClaudeVersionEntry(path.join(versionsDir, latest));
|
|
690
|
-
if (resolved) {
|
|
691
|
-
return resolved;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
catch {
|
|
696
|
-
// continue
|
|
697
|
-
}
|
|
698
944
|
for (const name of getClaudeCommandNames()) {
|
|
699
945
|
const claudeInPath = await findExecutableInPath(name);
|
|
700
946
|
if (claudeInPath && !(await isManagedClaudeWrapper(claudeInPath))) {
|
|
@@ -938,6 +1184,62 @@ const runLaunchCodex = async (args) => {
|
|
|
938
1184
|
child.on("error", reject);
|
|
939
1185
|
});
|
|
940
1186
|
};
|
|
1187
|
+
const resolveCodexAppBundle = async () => {
|
|
1188
|
+
if (process.platform !== "darwin") {
|
|
1189
|
+
throw new Error("Codex desktop app launch is currently supported on macOS only.");
|
|
1190
|
+
}
|
|
1191
|
+
const candidates = [
|
|
1192
|
+
"/Applications/Codex.app",
|
|
1193
|
+
path.join(os.homedir(), "Applications", "Codex.app"),
|
|
1194
|
+
];
|
|
1195
|
+
for (const candidate of candidates) {
|
|
1196
|
+
if (await pathExists(candidate)) {
|
|
1197
|
+
return candidate;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
throw new Error("Unable to locate Codex.app. Install the Codex desktop app first.");
|
|
1201
|
+
};
|
|
1202
|
+
const runConfigureCodexApp = async () => {
|
|
1203
|
+
const config = await ensureConfigured();
|
|
1204
|
+
const { configFile, updated } = await ensureCodexAppConfigured(config);
|
|
1205
|
+
console.log(updated
|
|
1206
|
+
? `Configured Codex desktop app routing in ${configFile}.`
|
|
1207
|
+
: `Codex desktop app routing is already configured in ${configFile}.`);
|
|
1208
|
+
console.log(`Codex desktop app model: ${getActiveModel(config)}`);
|
|
1209
|
+
};
|
|
1210
|
+
const runCodexAppToken = async () => {
|
|
1211
|
+
const config = await ensureConfigured();
|
|
1212
|
+
await ensureCodexAppConfigured(config);
|
|
1213
|
+
await ensureProxyRunning(config);
|
|
1214
|
+
console.log(config.proxyToken);
|
|
1215
|
+
};
|
|
1216
|
+
const runLaunchCodexApp = async (args) => {
|
|
1217
|
+
const codexAppBundle = await resolveCodexAppBundle();
|
|
1218
|
+
const config = await ensureConfigured();
|
|
1219
|
+
const { configFile, updated } = await ensureCodexAppConfigured(config);
|
|
1220
|
+
await ensureProxyRunning(config);
|
|
1221
|
+
console.error(`${updated ? "Configured" : "Using"} Codex desktop app routing in ${configFile}.`);
|
|
1222
|
+
console.error(`Opening Codex.app with ${getActiveModel(config)} through nvicode.`);
|
|
1223
|
+
console.error("If Codex.app was already running, quit and reopen it once to reload the provider config.");
|
|
1224
|
+
const child = spawn("/usr/bin/open", ["-a", codexAppBundle, ...args], {
|
|
1225
|
+
stdio: "inherit",
|
|
1226
|
+
windowsHide: true,
|
|
1227
|
+
});
|
|
1228
|
+
await new Promise((resolve, reject) => {
|
|
1229
|
+
child.on("exit", (code, signal) => {
|
|
1230
|
+
if (signal) {
|
|
1231
|
+
reject(new Error(`Unable to open Codex.app: open exited with signal ${signal}`));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if ((code ?? 0) !== 0) {
|
|
1235
|
+
reject(new Error(`Unable to open Codex.app: open exited with code ${code ?? 0}`));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
resolve();
|
|
1239
|
+
});
|
|
1240
|
+
child.on("error", reject);
|
|
1241
|
+
});
|
|
1242
|
+
};
|
|
941
1243
|
const runLaunchClaude = async (args) => {
|
|
942
1244
|
const config = await ensureConfigured();
|
|
943
1245
|
const routingStatus = await ensurePersistentClaudeRouting().catch(() => "skipped");
|
|
@@ -949,7 +1251,7 @@ const runLaunchClaude = async (args) => {
|
|
|
949
1251
|
...process.env,
|
|
950
1252
|
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
|
951
1253
|
ANTHROPIC_AUTH_TOKEN: activeApiKey,
|
|
952
|
-
ANTHROPIC_API_KEY:
|
|
1254
|
+
ANTHROPIC_API_KEY: activeApiKey,
|
|
953
1255
|
ANTHROPIC_MODEL: activeModel,
|
|
954
1256
|
ANTHROPIC_DEFAULT_SONNET_MODEL: activeModel,
|
|
955
1257
|
ANTHROPIC_DEFAULT_OPUS_MODEL: activeModel,
|
|
@@ -962,7 +1264,7 @@ const runLaunchClaude = async (args) => {
|
|
|
962
1264
|
...process.env,
|
|
963
1265
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.proxyPort}`,
|
|
964
1266
|
ANTHROPIC_AUTH_TOKEN: config.proxyToken,
|
|
965
|
-
ANTHROPIC_API_KEY:
|
|
1267
|
+
ANTHROPIC_API_KEY: config.proxyToken,
|
|
966
1268
|
ANTHROPIC_MODEL: activeModel,
|
|
967
1269
|
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1",
|
|
968
1270
|
ANTHROPIC_CUSTOM_MODEL_OPTION: activeModel,
|
|
@@ -1015,6 +1317,10 @@ const main = async () => {
|
|
|
1015
1317
|
await runServe();
|
|
1016
1318
|
return;
|
|
1017
1319
|
}
|
|
1320
|
+
if (command === "codex-app-token") {
|
|
1321
|
+
await runCodexAppToken();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1018
1324
|
if (command === "models") {
|
|
1019
1325
|
const config = await loadConfig();
|
|
1020
1326
|
await printModels(config.provider, getActiveApiKey(config) || undefined);
|
|
@@ -1045,6 +1351,10 @@ const main = async () => {
|
|
|
1045
1351
|
await runSelectModel();
|
|
1046
1352
|
return;
|
|
1047
1353
|
}
|
|
1354
|
+
if (command === "configure" && rest[0] === "codex-app") {
|
|
1355
|
+
await runConfigureCodexApp();
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1048
1358
|
if (command === "launch") {
|
|
1049
1359
|
if (rest[0] === "claude") {
|
|
1050
1360
|
await runLaunchClaude(rest.slice(1));
|
|
@@ -1058,7 +1368,11 @@ const main = async () => {
|
|
|
1058
1368
|
await runLaunchCodex(rest.slice(1));
|
|
1059
1369
|
return;
|
|
1060
1370
|
}
|
|
1061
|
-
|
|
1371
|
+
if (rest[0] === "codex-app") {
|
|
1372
|
+
await runLaunchCodexApp(rest.slice(1));
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
throw new Error("Supported launch targets are `claude`, `openclaw`, `codex`, and `codex-app`.");
|
|
1062
1376
|
}
|
|
1063
1377
|
throw new Error(`Unknown command: ${command}`);
|
|
1064
1378
|
};
|
package/dist/config.js
CHANGED
|
@@ -4,9 +4,20 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
const DEFAULT_PROXY_PORT = 8788;
|
|
6
6
|
const DEFAULT_PROVIDER = "nvidia";
|
|
7
|
-
const DEFAULT_NVIDIA_MODEL = "moonshotai/kimi-k2.
|
|
7
|
+
const DEFAULT_NVIDIA_MODEL = "moonshotai/kimi-k2.6";
|
|
8
8
|
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
|
|
9
9
|
const DEFAULT_MAX_REQUESTS_PER_MINUTE = 40;
|
|
10
|
+
const NVIDIA_MODEL_ALIASES = {
|
|
11
|
+
"moonshotai/kimi-k2.5": "moonshotai/kimi-k2.6",
|
|
12
|
+
"deepseek/deepseek-v4-pro": "deepseek-ai/deepseek-v4-flash",
|
|
13
|
+
"deepseek-ai/deepseek-v4-pro": "deepseek-ai/deepseek-v4-flash",
|
|
14
|
+
"deepseek-ai/deepseek-v3.2": "deepseek-ai/deepseek-v4-flash",
|
|
15
|
+
};
|
|
16
|
+
const OPENROUTER_MODEL_ALIASES = {
|
|
17
|
+
"qwen/qwen3.6-plus-preview:free": "qwen/qwen3-coder:free",
|
|
18
|
+
};
|
|
19
|
+
const normalizeNvidiaModel = (model) => NVIDIA_MODEL_ALIASES[model] || model;
|
|
20
|
+
const normalizeOpenRouterModel = (model) => OPENROUTER_MODEL_ALIASES[model] || model;
|
|
10
21
|
const getEnvNumber = (name) => {
|
|
11
22
|
const raw = process.env[name];
|
|
12
23
|
if (!raw) {
|
|
@@ -61,9 +72,9 @@ const withDefaults = (config) => {
|
|
|
61
72
|
return {
|
|
62
73
|
provider: config.provider === "openrouter" ? "openrouter" : DEFAULT_PROVIDER,
|
|
63
74
|
nvidiaApiKey: config.nvidiaApiKey?.trim() || legacyApiKey,
|
|
64
|
-
nvidiaModel: config.nvidiaModel?.trim() || legacyModel,
|
|
75
|
+
nvidiaModel: normalizeNvidiaModel(config.nvidiaModel?.trim() || legacyModel),
|
|
65
76
|
openrouterApiKey: config.openrouterApiKey?.trim() || "",
|
|
66
|
-
openrouterModel: config.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL,
|
|
77
|
+
openrouterModel: normalizeOpenRouterModel(config.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL),
|
|
67
78
|
proxyPort: Number.isInteger(config.proxyPort) && config.proxyPort > 0
|
|
68
79
|
? config.proxyPort
|
|
69
80
|
: DEFAULT_PROXY_PORT,
|
package/dist/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const NVIDIA_CURATED_MODELS = [
|
|
2
2
|
{
|
|
3
|
-
id: "moonshotai/kimi-k2.
|
|
4
|
-
label: "Kimi K2.
|
|
3
|
+
id: "moonshotai/kimi-k2.6",
|
|
4
|
+
label: "Kimi K2.6",
|
|
5
5
|
description: "Strong coding and agentic workflow model.",
|
|
6
6
|
},
|
|
7
7
|
{
|
|
@@ -15,9 +15,9 @@ export const NVIDIA_CURATED_MODELS = [
|
|
|
15
15
|
description: "General purpose reasoning model with code capability.",
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
id: "deepseek-ai/deepseek-
|
|
19
|
-
label: "DeepSeek
|
|
20
|
-
description: "
|
|
18
|
+
id: "deepseek-ai/deepseek-v4-flash",
|
|
19
|
+
label: "DeepSeek V4 Flash",
|
|
20
|
+
description: "Responsive DeepSeek V4-family model.",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
id: "mistralai/codestral-22b-instruct-v0.1",
|
|
@@ -30,29 +30,75 @@ export const NVIDIA_CURATED_MODELS = [
|
|
|
30
30
|
description: "Smaller coding-focused Qwen model.",
|
|
31
31
|
},
|
|
32
32
|
];
|
|
33
|
+
const NVIDIA_MODEL_FAMILIES = [
|
|
34
|
+
{
|
|
35
|
+
name: "Kimi",
|
|
36
|
+
labelPrefix: "Kimi",
|
|
37
|
+
description: "Latest available Kimi model from NVIDIA.",
|
|
38
|
+
match: /^moonshotai\/kimi/i,
|
|
39
|
+
prefer: [/k2\.5/i, /thinking/i, /instruct/i],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "DeepSeek",
|
|
43
|
+
labelPrefix: "DeepSeek",
|
|
44
|
+
description: "Latest available DeepSeek model from NVIDIA.",
|
|
45
|
+
match: /^deepseek-ai\/deepseek/i,
|
|
46
|
+
prefer: [/v4-flash/i, /v4-pro/i, /v4/i, /v3\.2/i, /coder/i],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "GLM",
|
|
50
|
+
labelPrefix: "GLM",
|
|
51
|
+
description: "Latest available GLM model from NVIDIA.",
|
|
52
|
+
match: /^z-ai\/glm/i,
|
|
53
|
+
prefer: [/5\.1/i, /5/i, /4\.7/i],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "Qwen",
|
|
57
|
+
labelPrefix: "Qwen",
|
|
58
|
+
description: "Latest available Qwen coding model from NVIDIA.",
|
|
59
|
+
match: /^qwen\/qwen/i,
|
|
60
|
+
prefer: [/qwen3-coder/i, /qwen3\.5/i, /qwen3-next/i, /qwen3/i, /qwen2\.5-coder/i],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
33
63
|
export const OPENROUTER_CURATED_MODELS = [
|
|
34
64
|
{
|
|
35
|
-
id: "
|
|
36
|
-
label: "
|
|
37
|
-
description: "Free OpenRouter
|
|
65
|
+
id: "moonshotai/kimi-k2.6:free",
|
|
66
|
+
label: "Kimi K2.6 (Free)",
|
|
67
|
+
description: "Free OpenRouter Kimi endpoint for coding and agentic workflows.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "qwen/qwen3-coder:free",
|
|
71
|
+
label: "Qwen3 Coder (Free)",
|
|
72
|
+
description: "Free OpenRouter Qwen coding model.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "openai/gpt-oss-120b:free",
|
|
76
|
+
label: "GPT-OSS 120B (Free)",
|
|
77
|
+
description: "Free OpenRouter open-weight reasoning model.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "nvidia/nemotron-3-super-120b-a12b:free",
|
|
81
|
+
label: "Nemotron 3 Super 120B (Free)",
|
|
82
|
+
description: "Free OpenRouter NVIDIA Nemotron model.",
|
|
38
83
|
},
|
|
39
84
|
{
|
|
40
|
-
id: "
|
|
41
|
-
label: "
|
|
42
|
-
description: "
|
|
85
|
+
id: "google/gemma-4-31b-it:free",
|
|
86
|
+
label: "Gemma 4 31B (Free)",
|
|
87
|
+
description: "Free OpenRouter Gemma instruction model.",
|
|
43
88
|
},
|
|
44
89
|
{
|
|
45
|
-
id: "
|
|
46
|
-
label: "
|
|
47
|
-
description: "
|
|
90
|
+
id: "meta-llama/llama-3.3-70b-instruct:free",
|
|
91
|
+
label: "Llama 3.3 70B (Free)",
|
|
92
|
+
description: "Free OpenRouter Llama instruction model.",
|
|
48
93
|
},
|
|
49
94
|
{
|
|
50
|
-
id: "
|
|
51
|
-
label: "
|
|
52
|
-
description: "
|
|
95
|
+
id: "qwen/qwen3-next-80b-a3b-instruct:free",
|
|
96
|
+
label: "Qwen3 Next 80B (Free)",
|
|
97
|
+
description: "Free OpenRouter Qwen instruction model.",
|
|
53
98
|
},
|
|
54
99
|
];
|
|
55
100
|
const MODELS_URL = "https://integrate.api.nvidia.com/v1/models";
|
|
101
|
+
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
56
102
|
export const fetchAvailableModelIds = async (apiKey) => {
|
|
57
103
|
const response = await fetch(MODELS_URL, {
|
|
58
104
|
headers: {
|
|
@@ -71,14 +117,218 @@ export const fetchAvailableModelIds = async (apiKey) => {
|
|
|
71
117
|
}
|
|
72
118
|
return ids;
|
|
73
119
|
};
|
|
120
|
+
const OPENROUTER_MODEL_FAMILIES = [
|
|
121
|
+
{
|
|
122
|
+
name: "Kimi",
|
|
123
|
+
labelPrefix: "Kimi",
|
|
124
|
+
description: "Top free Kimi model from OpenRouter.",
|
|
125
|
+
match: /^(?:~)?moonshotai\/kimi/i,
|
|
126
|
+
prefer: [/k2\.6/i, /k2\.5/i, /latest/i, /thinking/i],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "Qwen",
|
|
130
|
+
labelPrefix: "Qwen",
|
|
131
|
+
description: "Top free Qwen coding model from OpenRouter.",
|
|
132
|
+
match: /^qwen\/qwen/i,
|
|
133
|
+
prefer: [/qwen3-coder/i, /qwen3-next/i, /qwen3\.7/i, /qwen3\.6/i, /qwen3/i],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "GPT-OSS",
|
|
137
|
+
labelPrefix: "GPT-OSS",
|
|
138
|
+
description: "Top free GPT-OSS model from OpenRouter.",
|
|
139
|
+
match: /^openai\/gpt-oss/i,
|
|
140
|
+
prefer: [/120b/i, /20b/i],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "Nemotron",
|
|
144
|
+
labelPrefix: "Nemotron",
|
|
145
|
+
description: "Top free NVIDIA Nemotron model from OpenRouter.",
|
|
146
|
+
match: /^nvidia\/nemotron/i,
|
|
147
|
+
prefer: [/super/i, /ultra/i, /nano-omni/i, /nano-30b/i],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "Gemma",
|
|
151
|
+
labelPrefix: "Gemma",
|
|
152
|
+
description: "Top free Gemma model from OpenRouter.",
|
|
153
|
+
match: /^google\/gemma/i,
|
|
154
|
+
prefer: [/31b/i, /26b/i],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "Llama",
|
|
158
|
+
labelPrefix: "Llama",
|
|
159
|
+
description: "Top free Llama-family model from OpenRouter.",
|
|
160
|
+
match: /^(meta-llama\/llama|nousresearch\/hermes-3-llama)/i,
|
|
161
|
+
prefer: [/3\.3-70b/i, /405b/i, /3\.2-3b/i],
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
export const fetchOpenRouterModels = async (apiKey) => {
|
|
165
|
+
const headers = {
|
|
166
|
+
Accept: "application/json",
|
|
167
|
+
};
|
|
168
|
+
if (apiKey) {
|
|
169
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
170
|
+
}
|
|
171
|
+
const response = await fetch(OPENROUTER_MODELS_URL, { headers });
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
throw new Error(`Unable to fetch OpenRouter models: HTTP ${response.status} ${response.statusText}`);
|
|
174
|
+
}
|
|
175
|
+
const body = (await response.json());
|
|
176
|
+
return body.data ?? [];
|
|
177
|
+
};
|
|
178
|
+
const formatModelNameToken = (part) => {
|
|
179
|
+
const normalized = part.toLowerCase();
|
|
180
|
+
const brandNames = {
|
|
181
|
+
deepseek: "DeepSeek",
|
|
182
|
+
gpt: "GPT",
|
|
183
|
+
glm: "GLM",
|
|
184
|
+
kimi: "Kimi",
|
|
185
|
+
llama: "Llama",
|
|
186
|
+
oss: "OSS",
|
|
187
|
+
qwen: "Qwen",
|
|
188
|
+
};
|
|
189
|
+
if (brandNames[normalized]) {
|
|
190
|
+
return brandNames[normalized];
|
|
191
|
+
}
|
|
192
|
+
if (/^[vk]\d/i.test(part) || /^\d+b$/i.test(part) || /^a\d+b$/i.test(part)) {
|
|
193
|
+
return part.toUpperCase();
|
|
194
|
+
}
|
|
195
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
196
|
+
};
|
|
197
|
+
const titleCaseModelPart = (value) => value
|
|
198
|
+
.split(/[-_]/)
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.map(formatModelNameToken)
|
|
201
|
+
.join(" ");
|
|
202
|
+
const formatDynamicLabel = (family, id) => {
|
|
203
|
+
const modelName = (id.split("/").at(-1) || id).replace(/:free$/i, "");
|
|
204
|
+
return `${family.labelPrefix}: ${titleCaseModelPart(modelName)}`;
|
|
205
|
+
};
|
|
206
|
+
const getVersionScore = (id) => {
|
|
207
|
+
const versionNumbers = [...id.matchAll(/\d+(?:\.\d+)?/g)]
|
|
208
|
+
.map((match) => Number(match[0]))
|
|
209
|
+
.filter((value) => Number.isFinite(value));
|
|
210
|
+
if (versionNumbers.length === 0) {
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
return Math.max(...versionNumbers);
|
|
214
|
+
};
|
|
215
|
+
const scoreFamilyModel = (family, id) => {
|
|
216
|
+
let score = getVersionScore(id);
|
|
217
|
+
family.prefer.forEach((pattern, index) => {
|
|
218
|
+
if (pattern.test(id)) {
|
|
219
|
+
score += (family.prefer.length - index) * 1000;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
if (/preview|beta|experimental/i.test(id)) {
|
|
223
|
+
score -= 10;
|
|
224
|
+
}
|
|
225
|
+
return score;
|
|
226
|
+
};
|
|
227
|
+
const pickFamilyModel = (family, ids) => {
|
|
228
|
+
const candidates = ids.filter((id) => family.match.test(id));
|
|
229
|
+
if (candidates.length === 0) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const [best] = candidates.sort((left, right) => {
|
|
233
|
+
const scoreDelta = scoreFamilyModel(family, right) - scoreFamilyModel(family, left);
|
|
234
|
+
return scoreDelta || right.localeCompare(left, undefined, { numeric: true });
|
|
235
|
+
});
|
|
236
|
+
if (!best) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
id: best,
|
|
241
|
+
label: formatDynamicLabel(family, best),
|
|
242
|
+
description: family.description,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
const isFreeOpenRouterModel = (model) => {
|
|
246
|
+
if (model.id?.endsWith(":free")) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
const prompt = Number(model.pricing?.prompt ?? Number.NaN);
|
|
250
|
+
const completion = Number(model.pricing?.completion ?? Number.NaN);
|
|
251
|
+
return prompt === 0 && completion === 0;
|
|
252
|
+
};
|
|
253
|
+
const scoreOpenRouterFamilyModel = (family, model) => {
|
|
254
|
+
const id = model.id || "";
|
|
255
|
+
const idWithoutDates = id.replace(/\b20\d{6}\b/g, "");
|
|
256
|
+
let score = getVersionScore(idWithoutDates);
|
|
257
|
+
family.prefer.forEach((pattern, index) => {
|
|
258
|
+
if (pattern.test(id)) {
|
|
259
|
+
score += (family.prefer.length - index) * 1000;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
if (isFreeOpenRouterModel(model)) {
|
|
263
|
+
score += 500;
|
|
264
|
+
}
|
|
265
|
+
if (/preview|beta|experimental/i.test(id)) {
|
|
266
|
+
score -= 10;
|
|
267
|
+
}
|
|
268
|
+
if (id.startsWith("~")) {
|
|
269
|
+
score -= 100;
|
|
270
|
+
}
|
|
271
|
+
return score;
|
|
272
|
+
};
|
|
273
|
+
const pickOpenRouterFamilyModel = (family, models) => {
|
|
274
|
+
const candidates = models.filter((model) => {
|
|
275
|
+
const id = model.id || "";
|
|
276
|
+
return family.match.test(id);
|
|
277
|
+
});
|
|
278
|
+
if (candidates.length === 0) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const [best] = candidates.sort((left, right) => {
|
|
282
|
+
const scoreDelta = scoreOpenRouterFamilyModel(family, right) -
|
|
283
|
+
scoreOpenRouterFamilyModel(family, left);
|
|
284
|
+
return (scoreDelta ||
|
|
285
|
+
(right.id || "").localeCompare(left.id || "", undefined, {
|
|
286
|
+
numeric: true,
|
|
287
|
+
}));
|
|
288
|
+
});
|
|
289
|
+
if (!best?.id) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const freeLabel = isFreeOpenRouterModel(best) ? " (Free)" : "";
|
|
293
|
+
return {
|
|
294
|
+
id: best.id,
|
|
295
|
+
label: `${formatDynamicLabel(family, best.id)}${freeLabel}`,
|
|
296
|
+
description: family.description,
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
export const getRecommendedNvidiaModels = (available) => {
|
|
300
|
+
const ids = [...available];
|
|
301
|
+
const picked = NVIDIA_MODEL_FAMILIES
|
|
302
|
+
.map((family) => pickFamilyModel(family, ids))
|
|
303
|
+
.filter((model) => Boolean(model));
|
|
304
|
+
const seen = new Set(picked.map((model) => model.id));
|
|
305
|
+
const fallback = NVIDIA_CURATED_MODELS.filter((model) => available.has(model.id) && !seen.has(model.id));
|
|
306
|
+
return [...picked, ...fallback];
|
|
307
|
+
};
|
|
308
|
+
export const getRecommendedOpenRouterModels = (models) => {
|
|
309
|
+
const freeModels = models.filter(isFreeOpenRouterModel);
|
|
310
|
+
const picked = OPENROUTER_MODEL_FAMILIES
|
|
311
|
+
.map((family) => pickOpenRouterFamilyModel(family, freeModels))
|
|
312
|
+
.filter((model) => Boolean(model));
|
|
313
|
+
const seen = new Set(picked.map((model) => model.id));
|
|
314
|
+
const fallback = OPENROUTER_CURATED_MODELS.filter((model) => !seen.has(model.id));
|
|
315
|
+
return [...picked, ...fallback];
|
|
316
|
+
};
|
|
74
317
|
export const getRecommendedModels = async (provider, apiKey) => {
|
|
75
318
|
if (provider === "openrouter") {
|
|
76
|
-
|
|
319
|
+
try {
|
|
320
|
+
const available = await fetchOpenRouterModels(apiKey);
|
|
321
|
+
const dynamic = getRecommendedOpenRouterModels(available);
|
|
322
|
+
return dynamic.length > 0 ? dynamic : OPENROUTER_CURATED_MODELS;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return OPENROUTER_CURATED_MODELS;
|
|
326
|
+
}
|
|
77
327
|
}
|
|
78
328
|
try {
|
|
79
329
|
const available = await fetchAvailableModelIds(apiKey);
|
|
80
|
-
const
|
|
81
|
-
return
|
|
330
|
+
const dynamic = getRecommendedNvidiaModels(available);
|
|
331
|
+
return dynamic.length > 0 ? dynamic : NVIDIA_CURATED_MODELS;
|
|
82
332
|
}
|
|
83
333
|
catch {
|
|
84
334
|
return NVIDIA_CURATED_MODELS;
|
package/dist/proxy.js
CHANGED
|
@@ -6,12 +6,33 @@ const NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions";
|
|
|
6
6
|
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
7
7
|
const DEFAULT_RETRY_DELAY_MS = 2_000;
|
|
8
8
|
const MAX_NVIDIA_RETRIES = 3;
|
|
9
|
+
const UPSTREAM_TIMEOUT_MS = 60_000;
|
|
10
|
+
const PROXY_PROTOCOL_VERSION = 2;
|
|
9
11
|
const sleep = async (ms) => {
|
|
10
12
|
if (ms <= 0) {
|
|
11
13
|
return;
|
|
12
14
|
}
|
|
13
15
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
16
|
};
|
|
17
|
+
const fetchWithTimeout = async (input, init) => {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
|
|
20
|
+
try {
|
|
21
|
+
return await fetch(input, {
|
|
22
|
+
...init,
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.name === "AbortError") {
|
|
28
|
+
throw new Error(`Upstream API timed out after ${UPSTREAM_TIMEOUT_MS / 1000}s`);
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
15
36
|
const parseRetryAfterMs = (value) => {
|
|
16
37
|
if (!value) {
|
|
17
38
|
return null;
|
|
@@ -415,14 +436,14 @@ const callNvidia = async (config, scheduleRequest, payload) => {
|
|
|
415
436
|
if (toolChoice) {
|
|
416
437
|
requestBody.tool_choice = toolChoice;
|
|
417
438
|
}
|
|
418
|
-
if (config.
|
|
439
|
+
if (config.provider === "nvidia") {
|
|
419
440
|
requestBody.chat_template_kwargs = {
|
|
420
|
-
thinking:
|
|
441
|
+
thinking: config.thinking,
|
|
421
442
|
};
|
|
422
443
|
}
|
|
423
444
|
const invoke = async () => {
|
|
424
445
|
for (let attempt = 0; attempt <= MAX_NVIDIA_RETRIES; attempt += 1) {
|
|
425
|
-
const response = await
|
|
446
|
+
const response = await fetchWithTimeout(NVIDIA_URL, {
|
|
426
447
|
method: "POST",
|
|
427
448
|
headers: {
|
|
428
449
|
Authorization: `Bearer ${config.nvidiaApiKey}`,
|
|
@@ -654,7 +675,7 @@ const callChatCompletions = async (config, scheduleRequest, body) => {
|
|
|
654
675
|
const apiKey = getActiveApiKey(config);
|
|
655
676
|
const invoke = async () => {
|
|
656
677
|
for (let attempt = 0; attempt <= MAX_NVIDIA_RETRIES; attempt += 1) {
|
|
657
|
-
const resp = await
|
|
678
|
+
const resp = await fetchWithTimeout(upstreamUrl, {
|
|
658
679
|
method: "POST",
|
|
659
680
|
headers: {
|
|
660
681
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -691,7 +712,9 @@ export const createProxyServer = (config) => {
|
|
|
691
712
|
if (url.pathname === "/health") {
|
|
692
713
|
sendJson(response, 200, {
|
|
693
714
|
ok: true,
|
|
694
|
-
|
|
715
|
+
proxyProtocolVersion: PROXY_PROTOCOL_VERSION,
|
|
716
|
+
provider: config.provider,
|
|
717
|
+
model: getActiveModel(config),
|
|
695
718
|
port: config.proxyPort,
|
|
696
719
|
thinking: config.thinking,
|
|
697
720
|
maxRequestsPerMinute: config.maxRequestsPerMinute,
|
|
@@ -715,6 +738,21 @@ export const createProxyServer = (config) => {
|
|
|
715
738
|
});
|
|
716
739
|
return;
|
|
717
740
|
}
|
|
741
|
+
if (request.method === "GET" && url.pathname === "/v1/models") {
|
|
742
|
+
sendJson(response, 200, {
|
|
743
|
+
object: "list",
|
|
744
|
+
data: [
|
|
745
|
+
{
|
|
746
|
+
id: getActiveModel(config),
|
|
747
|
+
object: "model",
|
|
748
|
+
created: 0,
|
|
749
|
+
owned_by: config.provider,
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
models: [],
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
718
756
|
if (request.method === "POST" && url.pathname === "/v1/messages") {
|
|
719
757
|
const rawBody = await readRequestBody(request);
|
|
720
758
|
const payload = JSON.parse(rawBody);
|
|
@@ -872,6 +910,11 @@ export const createProxyServer = (config) => {
|
|
|
872
910
|
max_tokens: payload.max_output_tokens ?? 16_384,
|
|
873
911
|
stream: false,
|
|
874
912
|
};
|
|
913
|
+
if (config.provider === "nvidia") {
|
|
914
|
+
chatBody.chat_template_kwargs = {
|
|
915
|
+
thinking: config.thinking,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
875
918
|
if (typeof payload.temperature === "number") {
|
|
876
919
|
chatBody.temperature = payload.temperature;
|
|
877
920
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nvicode",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Route Claude Code, Codex CLI, and OpenClaw through NVIDIA or OpenRouter with one setup.",
|
|
3
|
+
"version": "0.1.21",
|
|
4
|
+
"description": "Route Claude Code, Codex CLI, Codex desktop app, and OpenClaw through NVIDIA or OpenRouter with one setup.",
|
|
5
5
|
"author": "Dinesh Potla",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"claude-code",
|