nvicode 0.1.20 → 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,10 +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
20
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
21
23
 
22
24
  Supported environments:
23
25
  - macOS
@@ -44,17 +46,19 @@ Launch the tool you want:
44
46
  ```sh
45
47
  nvicode launch claude
46
48
  nvicode launch codex
49
+ nvicode launch codex-app
47
50
  nvicode launch openclaw
48
51
  ```
49
52
 
50
53
  Provider setup:
51
54
 
52
55
  - NVIDIA: get a free key from [NVIDIA Build API Keys](https://build.nvidia.com/settings/api-keys)
53
- - OpenRouter: use your OpenRouter API key
56
+ - OpenRouter: use your OpenRouter API key and pick from current free models
54
57
 
55
58
  What happens after first launch:
56
59
  - The first successful `nvicode launch claude` installs persistent plain `claude` routing.
57
60
  - The first successful `nvicode launch codex` installs persistent plain `codex` routing.
61
+ - `nvicode launch codex-app` configures and opens the Codex desktop app.
58
62
  - `nvicode launch openclaw` updates the default OpenClaw profile for the selected provider/model.
59
63
 
60
64
  After that, plain:
@@ -88,6 +92,10 @@ continues using your selected `nvicode` provider and model.
88
92
  - Codex CLI:
89
93
  - uses the local `nvicode` proxy
90
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
91
99
  - OpenClaw:
92
100
  - updates the default OpenClaw config for the selected provider/model
93
101
  - restart the gateway after config changes:
@@ -104,6 +112,8 @@ Common commands:
104
112
  nvicode select model
105
113
  nvicode launch claude
106
114
  nvicode launch codex
115
+ nvicode configure codex-app
116
+ nvicode launch codex-app
107
117
  nvicode launch openclaw
108
118
  nvicode dashboard
109
119
  nvicode usage
@@ -118,8 +128,12 @@ nvicode launch codex "Explain this project"
118
128
  Behavior notes:
119
129
  - `nvicode select model` asks for provider, optional API key update, and model choice in one guided flow.
120
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.
121
133
  - Claude Code uses direct OpenRouter mode for OpenRouter, and proxy mode for NVIDIA.
122
- - 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.
123
137
  - `nvicode usage`, `activity`, and `dashboard` are currently focused on NVIDIA proxy sessions.
124
138
  - OpenRouter does not currently produce the same local usage visibility as the NVIDIA proxy flow.
125
139
  - NVIDIA requests are paced to `40 RPM` by default. Override with `NVICODE_MAX_RPM` if your account allows more.
@@ -133,6 +147,7 @@ If your NVIDIA endpoint is not free, override local cost estimates with `NVICODE
133
147
 
134
148
  - Claude Code must already be installed to use `nvicode launch claude`.
135
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`.
136
151
  - OpenClaw must already be installed to use `nvicode launch openclaw`. Install with `npm install -g openclaw@latest`.
137
152
  - Node.js 20 or newer is required to install `nvicode`.
138
153
  - OpenClaw itself requires Node.js `>=22.14.0`.
@@ -153,4 +168,4 @@ npm link
153
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.
154
169
  - The proxy supports basic text, tool calls, tool results, and token count estimation.
155
170
  - The proxy includes upstream request pacing and retries on NVIDIA `429` responses.
156
- - 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
@@ -568,6 +611,65 @@ const resolvePersistentClaudeCommand = async () => {
568
611
  }
569
612
  return null;
570
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
+ };
571
673
  const resolveLatestClaudeManagedVersion = async () => {
572
674
  const versionsDir = path.join(os.homedir(), ".local", "share", "claude", "versions");
573
675
  try {
@@ -620,6 +722,101 @@ const writeExecutableTextFile = async (targetPath, contents) => {
620
722
  await fs.chmod(targetPath, 0o755);
621
723
  }
622
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
+ };
623
820
  const ensurePersistentClaudeRouting = async () => {
624
821
  const claudeCommandPath = await resolvePersistentClaudeCommand();
625
822
  if (!claudeCommandPath) {
@@ -627,25 +824,31 @@ const ensurePersistentClaudeRouting = async () => {
627
824
  }
628
825
  const wrapperContents = renderClaudeWrapper();
629
826
  const { wrapperPath, nativePath } = await getWrapperInstallPaths(claudeCommandPath);
827
+ let routingStatus = "skipped";
630
828
  if (await isManagedClaudeWrapper(wrapperPath)) {
631
829
  const currentWrapper = await readIfExists(wrapperPath);
632
830
  if (currentWrapper === wrapperContents) {
633
- return "already";
831
+ routingStatus = "already";
832
+ }
833
+ else {
834
+ await writeExecutableTextFile(wrapperPath, wrapperContents);
835
+ routingStatus = "updated";
634
836
  }
635
- await writeExecutableTextFile(wrapperPath, wrapperContents);
636
- return "updated";
637
- }
638
- if (!(await pathExists(nativePath))) {
639
- await fs.rename(claudeCommandPath, nativePath);
640
- }
641
- else if (claudeCommandPath !== wrapperPath && await pathExists(wrapperPath)) {
642
- await fs.rm(wrapperPath, { force: true });
643
837
  }
644
- else if (claudeCommandPath === wrapperPath) {
645
- 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";
646
850
  }
647
- await writeExecutableTextFile(wrapperPath, wrapperContents);
648
- return "installed";
851
+ return mergeRoutingStatus(routingStatus, await ensureClaudeShadowRouting(claudeCommandPath, wrapperContents));
649
852
  };
650
853
  const findExistingCodexNativeInDirectory = async (directory) => {
651
854
  for (const name of getCodexNativeNames()) {
@@ -981,6 +1184,62 @@ const runLaunchCodex = async (args) => {
981
1184
  child.on("error", reject);
982
1185
  });
983
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
+ };
984
1243
  const runLaunchClaude = async (args) => {
985
1244
  const config = await ensureConfigured();
986
1245
  const routingStatus = await ensurePersistentClaudeRouting().catch(() => "skipped");
@@ -992,7 +1251,7 @@ const runLaunchClaude = async (args) => {
992
1251
  ...process.env,
993
1252
  ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
994
1253
  ANTHROPIC_AUTH_TOKEN: activeApiKey,
995
- ANTHROPIC_API_KEY: "",
1254
+ ANTHROPIC_API_KEY: activeApiKey,
996
1255
  ANTHROPIC_MODEL: activeModel,
997
1256
  ANTHROPIC_DEFAULT_SONNET_MODEL: activeModel,
998
1257
  ANTHROPIC_DEFAULT_OPUS_MODEL: activeModel,
@@ -1005,7 +1264,7 @@ const runLaunchClaude = async (args) => {
1005
1264
  ...process.env,
1006
1265
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.proxyPort}`,
1007
1266
  ANTHROPIC_AUTH_TOKEN: config.proxyToken,
1008
- ANTHROPIC_API_KEY: "",
1267
+ ANTHROPIC_API_KEY: config.proxyToken,
1009
1268
  ANTHROPIC_MODEL: activeModel,
1010
1269
  CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1",
1011
1270
  ANTHROPIC_CUSTOM_MODEL_OPTION: activeModel,
@@ -1058,6 +1317,10 @@ const main = async () => {
1058
1317
  await runServe();
1059
1318
  return;
1060
1319
  }
1320
+ if (command === "codex-app-token") {
1321
+ await runCodexAppToken();
1322
+ return;
1323
+ }
1061
1324
  if (command === "models") {
1062
1325
  const config = await loadConfig();
1063
1326
  await printModels(config.provider, getActiveApiKey(config) || undefined);
@@ -1088,6 +1351,10 @@ const main = async () => {
1088
1351
  await runSelectModel();
1089
1352
  return;
1090
1353
  }
1354
+ if (command === "configure" && rest[0] === "codex-app") {
1355
+ await runConfigureCodexApp();
1356
+ return;
1357
+ }
1091
1358
  if (command === "launch") {
1092
1359
  if (rest[0] === "claude") {
1093
1360
  await runLaunchClaude(rest.slice(1));
@@ -1101,7 +1368,11 @@ const main = async () => {
1101
1368
  await runLaunchCodex(rest.slice(1));
1102
1369
  return;
1103
1370
  }
1104
- 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`.");
1105
1376
  }
1106
1377
  throw new Error(`Unknown command: ${command}`);
1107
1378
  };
package/dist/config.js CHANGED
@@ -4,15 +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
10
  const NVIDIA_MODEL_ALIASES = {
11
+ "moonshotai/kimi-k2.5": "moonshotai/kimi-k2.6",
11
12
  "deepseek/deepseek-v4-pro": "deepseek-ai/deepseek-v4-flash",
12
13
  "deepseek-ai/deepseek-v4-pro": "deepseek-ai/deepseek-v4-flash",
13
14
  "deepseek-ai/deepseek-v3.2": "deepseek-ai/deepseek-v4-flash",
14
15
  };
16
+ const OPENROUTER_MODEL_ALIASES = {
17
+ "qwen/qwen3.6-plus-preview:free": "qwen/qwen3-coder:free",
18
+ };
15
19
  const normalizeNvidiaModel = (model) => NVIDIA_MODEL_ALIASES[model] || model;
20
+ const normalizeOpenRouterModel = (model) => OPENROUTER_MODEL_ALIASES[model] || model;
16
21
  const getEnvNumber = (name) => {
17
22
  const raw = process.env[name];
18
23
  if (!raw) {
@@ -69,7 +74,7 @@ const withDefaults = (config) => {
69
74
  nvidiaApiKey: config.nvidiaApiKey?.trim() || legacyApiKey,
70
75
  nvidiaModel: normalizeNvidiaModel(config.nvidiaModel?.trim() || legacyModel),
71
76
  openrouterApiKey: config.openrouterApiKey?.trim() || "",
72
- openrouterModel: config.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL,
77
+ openrouterModel: normalizeOpenRouterModel(config.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL),
73
78
  proxyPort: Number.isInteger(config.proxyPort) && config.proxyPort > 0
74
79
  ? config.proxyPort
75
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
  {
@@ -62,27 +62,43 @@ const NVIDIA_MODEL_FAMILIES = [
62
62
  ];
63
63
  export const OPENROUTER_CURATED_MODELS = [
64
64
  {
65
- id: "qwen/qwen3.6-plus-preview:free",
66
- label: "Qwen 3.6 Plus Preview (Free)",
67
- 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
68
  },
69
69
  {
70
- id: "anthropic/claude-sonnet-4.6",
71
- label: "Claude Sonnet 4.6",
72
- description: "Recommended OpenRouter model for Claude Code compatibility.",
70
+ id: "qwen/qwen3-coder:free",
71
+ label: "Qwen3 Coder (Free)",
72
+ description: "Free OpenRouter Qwen coding model.",
73
73
  },
74
74
  {
75
- id: "anthropic/claude-opus-4.6",
76
- label: "Claude Opus 4.6",
77
- description: "Higher-end Anthropic model through OpenRouter.",
75
+ id: "openai/gpt-oss-120b:free",
76
+ label: "GPT-OSS 120B (Free)",
77
+ description: "Free OpenRouter open-weight reasoning model.",
78
78
  },
79
79
  {
80
- id: "anthropic/claude-haiku-4.5",
81
- label: "Claude Haiku 4.5",
82
- description: "Faster lower-cost Anthropic model through OpenRouter.",
80
+ id: "nvidia/nemotron-3-super-120b-a12b:free",
81
+ label: "Nemotron 3 Super 120B (Free)",
82
+ description: "Free OpenRouter NVIDIA Nemotron model.",
83
+ },
84
+ {
85
+ id: "google/gemma-4-31b-it:free",
86
+ label: "Gemma 4 31B (Free)",
87
+ description: "Free OpenRouter Gemma instruction model.",
88
+ },
89
+ {
90
+ id: "meta-llama/llama-3.3-70b-instruct:free",
91
+ label: "Llama 3.3 70B (Free)",
92
+ description: "Free OpenRouter Llama instruction model.",
93
+ },
94
+ {
95
+ id: "qwen/qwen3-next-80b-a3b-instruct:free",
96
+ label: "Qwen3 Next 80B (Free)",
97
+ description: "Free OpenRouter Qwen instruction model.",
83
98
  },
84
99
  ];
85
100
  const MODELS_URL = "https://integrate.api.nvidia.com/v1/models";
101
+ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
86
102
  export const fetchAvailableModelIds = async (apiKey) => {
87
103
  const response = await fetch(MODELS_URL, {
88
104
  headers: {
@@ -101,12 +117,73 @@ export const fetchAvailableModelIds = async (apiKey) => {
101
117
  }
102
118
  return ids;
103
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
+ };
104
178
  const formatModelNameToken = (part) => {
105
179
  const normalized = part.toLowerCase();
106
180
  const brandNames = {
107
181
  deepseek: "DeepSeek",
182
+ gpt: "GPT",
108
183
  glm: "GLM",
109
184
  kimi: "Kimi",
185
+ llama: "Llama",
186
+ oss: "OSS",
110
187
  qwen: "Qwen",
111
188
  };
112
189
  if (brandNames[normalized]) {
@@ -123,7 +200,7 @@ const titleCaseModelPart = (value) => value
123
200
  .map(formatModelNameToken)
124
201
  .join(" ");
125
202
  const formatDynamicLabel = (family, id) => {
126
- const modelName = id.split("/").at(-1) || id;
203
+ const modelName = (id.split("/").at(-1) || id).replace(/:free$/i, "");
127
204
  return `${family.labelPrefix}: ${titleCaseModelPart(modelName)}`;
128
205
  };
129
206
  const getVersionScore = (id) => {
@@ -165,7 +242,61 @@ const pickFamilyModel = (family, ids) => {
165
242
  description: family.description,
166
243
  };
167
244
  };
168
- const getDynamicNvidiaModels = (available) => {
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) => {
169
300
  const ids = [...available];
170
301
  const picked = NVIDIA_MODEL_FAMILIES
171
302
  .map((family) => pickFamilyModel(family, ids))
@@ -174,13 +305,29 @@ const getDynamicNvidiaModels = (available) => {
174
305
  const fallback = NVIDIA_CURATED_MODELS.filter((model) => available.has(model.id) && !seen.has(model.id));
175
306
  return [...picked, ...fallback];
176
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
+ };
177
317
  export const getRecommendedModels = async (provider, apiKey) => {
178
318
  if (provider === "openrouter") {
179
- 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
+ }
180
327
  }
181
328
  try {
182
329
  const available = await fetchAvailableModelIds(apiKey);
183
- const dynamic = getDynamicNvidiaModels(available);
330
+ const dynamic = getRecommendedNvidiaModels(available);
184
331
  return dynamic.length > 0 ? dynamic : NVIDIA_CURATED_MODELS;
185
332
  }
186
333
  catch {
package/dist/proxy.js CHANGED
@@ -738,6 +738,21 @@ export const createProxyServer = (config) => {
738
738
  });
739
739
  return;
740
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
+ }
741
756
  if (request.method === "POST" && url.pathname === "/v1/messages") {
742
757
  const rawBody = await readRequestBody(request);
743
758
  const payload = JSON.parse(rawBody);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nvicode",
3
- "version": "0.1.20",
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",