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 CHANGED
@@ -6,7 +6,7 @@
6
6
  [![node >=20](https://img.shields.io/badge/node-%3E%3D20-339933)](https://nodejs.org/)
7
7
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./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 currently uses the local `nvicode` proxy path.
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 readIfExists(targetPath);
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.5" }),
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(`Top popular ${providerLabel} models:`);
258
- await printModels(provider, getActiveApiKey(nextConfig));
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.6-plus-preview:free");
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 waitForHealthyProxy = async (port) => {
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:${port}/health`);
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.proxyPort)) {
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.proxyPort))) {
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
- return "already";
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 if (claudeCommandPath === wrapperPath) {
589
- await fs.rm(wrapperPath, { force: true });
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 writeExecutableTextFile(wrapperPath, wrapperContents);
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
- throw new Error("Supported launch targets are `claude`, `openclaw`, and `codex`.");
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.5";
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.5",
4
- label: "Kimi K2.5",
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-v3.2",
19
- label: "DeepSeek V3.2",
20
- description: "General coding and reasoning model.",
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: "qwen/qwen3.6-plus-preview:free",
36
- label: "Qwen 3.6 Plus Preview (Free)",
37
- description: "Free OpenRouter Qwen preview model.",
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: "anthropic/claude-sonnet-4.6",
41
- label: "Claude Sonnet 4.6",
42
- description: "Recommended OpenRouter model for Claude Code compatibility.",
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: "anthropic/claude-opus-4.6",
46
- label: "Claude Opus 4.6",
47
- description: "Higher-end Anthropic model through OpenRouter.",
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: "anthropic/claude-haiku-4.5",
51
- label: "Claude Haiku 4.5",
52
- description: "Faster lower-cost Anthropic model through OpenRouter.",
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
- return OPENROUTER_CURATED_MODELS;
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 curated = NVIDIA_CURATED_MODELS.filter((model) => available.has(model.id));
81
- return curated.length > 0 ? curated : NVIDIA_CURATED_MODELS;
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.thinking) {
439
+ if (config.provider === "nvidia") {
419
440
  requestBody.chat_template_kwargs = {
420
- thinking: true,
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 fetch(NVIDIA_URL, {
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 fetch(upstreamUrl, {
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
- model: config.nvidiaModel,
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.19",
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",