nvicode 0.1.16 → 0.1.18
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 +65 -26
- package/dist/cli.js +187 -24
- package/dist/proxy.js +267 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# NviCode
|
|
1
|
+
# NviCode
|
|
2
2
|
|
|
3
3
|
[](https://github.com/dineshpotla/nvicode/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/nvicode)
|
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
Route Claude Code, Codex CLI, and OpenClaw through NVIDIA or OpenRouter with one setup.
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
What it gives you:
|
|
14
|
+
- One guided setup flow for provider, key, and model
|
|
15
|
+
- Claude Code support
|
|
16
|
+
- Codex CLI support
|
|
17
|
+
- OpenClaw support
|
|
18
|
+
- NVIDIA proxy mode with pacing and local usage tracking
|
|
19
|
+
- OpenRouter direct mode for compatible models
|
|
12
20
|
|
|
13
21
|
Supported environments:
|
|
14
22
|
- macOS
|
|
@@ -18,37 +26,44 @@ Supported environments:
|
|
|
18
26
|
|
|
19
27
|
## Quickstart
|
|
20
28
|
|
|
21
|
-
Install
|
|
29
|
+
Install `nvicode`:
|
|
22
30
|
|
|
23
31
|
```sh
|
|
24
32
|
npm install -g nvicode
|
|
25
33
|
```
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
Choose provider, key, and model:
|
|
28
36
|
|
|
29
37
|
```sh
|
|
30
38
|
nvicode select model
|
|
31
39
|
```
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- NVIDIA: get a free key from [NVIDIA Build API Keys](https://build.nvidia.com/settings/api-keys)
|
|
36
|
-
- OpenRouter: use your OpenRouter API key
|
|
37
|
-
|
|
38
|
-
Launch Claude Code through your selected provider:
|
|
41
|
+
Launch the tool you want:
|
|
39
42
|
|
|
40
43
|
```sh
|
|
41
44
|
nvicode launch claude
|
|
45
|
+
nvicode launch codex
|
|
46
|
+
nvicode launch openclaw
|
|
42
47
|
```
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
Provider setup:
|
|
50
|
+
|
|
51
|
+
- NVIDIA: get a free key from [NVIDIA Build API Keys](https://build.nvidia.com/settings/api-keys)
|
|
52
|
+
- OpenRouter: use your OpenRouter API key
|
|
53
|
+
|
|
54
|
+
What happens after first launch:
|
|
55
|
+
- The first successful `nvicode launch claude` installs persistent plain `claude` routing.
|
|
56
|
+
- The first successful `nvicode launch codex` installs persistent plain `codex` routing.
|
|
57
|
+
- `nvicode launch openclaw` updates the default OpenClaw profile for the selected provider/model.
|
|
58
|
+
|
|
59
|
+
After that, plain:
|
|
46
60
|
|
|
47
61
|
```sh
|
|
48
62
|
claude
|
|
63
|
+
codex
|
|
49
64
|
```
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
continues using your selected `nvicode` provider and model.
|
|
52
67
|
|
|
53
68
|
## Screenshots
|
|
54
69
|
|
|
@@ -60,15 +75,35 @@ will continue using your selected `nvicode` provider and model.
|
|
|
60
75
|
|
|
61
76
|

|
|
62
77
|
|
|
63
|
-
### Launch
|
|
78
|
+
### Launch through your selected provider
|
|
64
79
|
|
|
65
80
|

|
|
66
81
|
|
|
82
|
+
## How It Works
|
|
83
|
+
|
|
84
|
+
- Claude Code:
|
|
85
|
+
- NVIDIA uses a local Anthropic-compatible proxy on `127.0.0.1:8788`
|
|
86
|
+
- OpenRouter connects directly to `https://openrouter.ai/api`
|
|
87
|
+
- Codex CLI:
|
|
88
|
+
- uses the local `nvicode` proxy
|
|
89
|
+
- `nvicode` configures Codex to talk to that proxy through the Responses API
|
|
90
|
+
- OpenClaw:
|
|
91
|
+
- updates the default OpenClaw config for the selected provider/model
|
|
92
|
+
- restart the gateway after config changes:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
openclaw gateway restart
|
|
96
|
+
```
|
|
97
|
+
|
|
67
98
|
## Commands
|
|
68
99
|
|
|
69
|
-
|
|
100
|
+
Common commands:
|
|
70
101
|
|
|
71
102
|
```sh
|
|
103
|
+
nvicode select model
|
|
104
|
+
nvicode launch claude
|
|
105
|
+
nvicode launch codex
|
|
106
|
+
nvicode launch openclaw
|
|
72
107
|
nvicode dashboard
|
|
73
108
|
nvicode usage
|
|
74
109
|
nvicode activity
|
|
@@ -76,25 +111,29 @@ nvicode models
|
|
|
76
111
|
nvicode config
|
|
77
112
|
nvicode auth
|
|
78
113
|
nvicode launch claude -p "Reply with exactly OK"
|
|
114
|
+
nvicode launch codex "Explain this project"
|
|
79
115
|
```
|
|
80
116
|
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
-
|
|
117
|
+
Behavior notes:
|
|
118
|
+
- `nvicode select model` asks for provider, optional API key update, and model choice in one guided flow.
|
|
119
|
+
- Claude Code uses direct OpenRouter mode for OpenRouter, and proxy mode for NVIDIA.
|
|
120
|
+
- Codex currently uses the local `nvicode` proxy path.
|
|
121
|
+
- `nvicode usage`, `activity`, and `dashboard` are currently focused on NVIDIA proxy sessions.
|
|
122
|
+
- OpenRouter does not currently produce the same local usage visibility as the NVIDIA proxy flow.
|
|
123
|
+
- NVIDIA requests are paced to `40 RPM` by default. Override with `NVICODE_MAX_RPM` if your account allows more.
|
|
84
124
|
|
|
85
125
|
In an interactive terminal, `nvicode usage` refreshes live every 2 seconds. When piped or redirected, it prints a single snapshot.
|
|
86
126
|
|
|
87
|
-
`nvicode select model` now asks for provider, optional API key update, and model choice in one guided flow.
|
|
88
|
-
If no API key is saved for the active provider yet, `nvicode` prompts for one on first use.
|
|
89
|
-
By default, the proxy paces upstream NVIDIA requests at `40 RPM`. Override that with `NVICODE_MAX_RPM` if your account has a different limit.
|
|
90
127
|
The usage dashboard compares your local NVIDIA run cost against Claude Opus 4.6 at `$5 / MTok input` and `$25 / MTok output`, based on Anthropic pricing as of `2026-03-30`.
|
|
91
128
|
If your NVIDIA endpoint is not free, override local cost estimates with `NVICODE_INPUT_USD_PER_MTOK` and `NVICODE_OUTPUT_USD_PER_MTOK`.
|
|
92
|
-
Local `usage`, `activity`, and `dashboard` commands are available for NVIDIA proxy sessions. OpenRouter sessions use OpenRouter's direct connection path instead.
|
|
93
129
|
|
|
94
130
|
## Requirements
|
|
95
131
|
|
|
96
|
-
- Claude Code must already be installed
|
|
132
|
+
- Claude Code must already be installed to use `nvicode launch claude`.
|
|
133
|
+
- Codex must already be installed to use `nvicode launch codex`. Install with `npm install -g @openai/codex`.
|
|
134
|
+
- OpenClaw must already be installed to use `nvicode launch openclaw`. Install with `npm install -g openclaw@latest`.
|
|
97
135
|
- Node.js 20 or newer is required to install `nvicode`.
|
|
136
|
+
- OpenClaw itself requires Node.js `>=22.14.0`.
|
|
98
137
|
- On native Windows, Claude Code itself requires Git for Windows. See the [Claude Code setup docs](https://code.claude.com/docs/en/setup).
|
|
99
138
|
|
|
100
139
|
## Local Development
|
|
@@ -112,4 +151,4 @@ npm link
|
|
|
112
151
|
- `thinking` is disabled by default because some NVIDIA reasoning models can consume the entire output budget and return no visible answer to Claude Code.
|
|
113
152
|
- The proxy supports basic text, tool calls, tool results, and token count estimation.
|
|
114
153
|
- The proxy includes upstream request pacing and retries on NVIDIA `429` responses.
|
|
115
|
-
- Claude Code
|
|
154
|
+
- Claude Code, Codex CLI, and OpenClaw remain the frontends; the selected provider/model becomes the backend.
|
package/dist/cli.js
CHANGED
|
@@ -18,16 +18,17 @@ const usage = () => {
|
|
|
18
18
|
console.log(`nvicode
|
|
19
19
|
|
|
20
20
|
Commands:
|
|
21
|
-
nvicode select model
|
|
22
|
-
nvicode models
|
|
23
|
-
nvicode auth
|
|
24
|
-
nvicode config
|
|
25
|
-
nvicode usage
|
|
26
|
-
nvicode activity
|
|
27
|
-
nvicode dashboard
|
|
28
|
-
nvicode launch claude [...]
|
|
21
|
+
nvicode select model Guided provider, key, and model selection
|
|
22
|
+
nvicode models Show recommended models for the active provider
|
|
23
|
+
nvicode auth Save or update the API key for the active provider
|
|
24
|
+
nvicode config Show current nvicode config
|
|
25
|
+
nvicode usage Show token usage and cost comparison
|
|
26
|
+
nvicode activity Show recent request activity
|
|
27
|
+
nvicode dashboard Show usage summary and recent activity
|
|
28
|
+
nvicode launch claude [...] Launch Claude Code through nvicode
|
|
29
29
|
nvicode launch openclaw [...] Launch OpenClaw through nvicode
|
|
30
|
-
nvicode
|
|
30
|
+
nvicode launch codex [...] Launch Codex through nvicode
|
|
31
|
+
nvicode serve Run the local proxy in the foreground
|
|
31
32
|
`);
|
|
32
33
|
};
|
|
33
34
|
const isWindows = process.platform === "win32";
|
|
@@ -48,9 +49,15 @@ const getClaudeCommandNames = () => isWindows ? ["claude.exe", "claude.cmd", "cl
|
|
|
48
49
|
const getOpenClawCommandNames = () => isWindows
|
|
49
50
|
? ["openclaw.exe", "openclaw.cmd", "openclaw.bat", "openclaw"]
|
|
50
51
|
: ["openclaw"];
|
|
52
|
+
const getCodexCommandNames = () => isWindows
|
|
53
|
+
? ["codex.exe", "codex.cmd", "codex.bat", "codex"]
|
|
54
|
+
: ["codex"];
|
|
51
55
|
const getClaudeNativeNames = () => isWindows
|
|
52
56
|
? ["claude-native.exe", "claude-native.cmd", "claude-native.bat", "claude-native"]
|
|
53
57
|
: ["claude-native"];
|
|
58
|
+
const getCodexNativeNames = () => isWindows
|
|
59
|
+
? ["codex-native.exe", "codex-native.cmd", "codex-native.bat", "codex-native"]
|
|
60
|
+
: ["codex-native"];
|
|
54
61
|
const pathExists = async (targetPath) => {
|
|
55
62
|
try {
|
|
56
63
|
await fs.access(targetPath, constants.F_OK);
|
|
@@ -88,6 +95,22 @@ const renderClaudeWrapper = () => {
|
|
|
88
95
|
"",
|
|
89
96
|
].join("\n");
|
|
90
97
|
};
|
|
98
|
+
const renderCodexWrapper = () => {
|
|
99
|
+
if (isWindows) {
|
|
100
|
+
return [
|
|
101
|
+
"@echo off",
|
|
102
|
+
`REM ${NVICODE_WRAPPER_MARKER}`,
|
|
103
|
+
`"${process.execPath}" "${__filename}" launch codex %*`,
|
|
104
|
+
"",
|
|
105
|
+
].join("\r\n");
|
|
106
|
+
}
|
|
107
|
+
return [
|
|
108
|
+
"#!/bin/sh",
|
|
109
|
+
`# ${NVICODE_WRAPPER_MARKER}`,
|
|
110
|
+
`exec "${process.execPath}" "${__filename}" launch codex "$@"`,
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
};
|
|
91
114
|
const question = async (prompt) => {
|
|
92
115
|
const rl = createInterface({
|
|
93
116
|
input: process.stdin,
|
|
@@ -150,6 +173,13 @@ const promptApiKeyUpdate = async (config, provider) => {
|
|
|
150
173
|
? { openrouterApiKey: nextKey, nvidiaApiKey: config.nvidiaApiKey }
|
|
151
174
|
: { nvidiaApiKey: nextKey, openrouterApiKey: config.openrouterApiKey };
|
|
152
175
|
};
|
|
176
|
+
const promptRequiredApiKey = async (providerLabel) => {
|
|
177
|
+
const apiKey = await question(`${providerLabel} API key: `);
|
|
178
|
+
if (!apiKey) {
|
|
179
|
+
throw new Error(`${providerLabel} API key is required.`);
|
|
180
|
+
}
|
|
181
|
+
return apiKey;
|
|
182
|
+
};
|
|
153
183
|
const ensureConfigured = async () => {
|
|
154
184
|
let config = await loadConfig();
|
|
155
185
|
let changed = false;
|
|
@@ -160,10 +190,8 @@ const ensureConfigured = async () => {
|
|
|
160
190
|
if (!process.stdin.isTTY) {
|
|
161
191
|
throw new Error(`Missing ${providerLabel} API key. Run \`nvicode auth\` first.`);
|
|
162
192
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
throw new Error(`${providerLabel} API key is required.`);
|
|
166
|
-
}
|
|
193
|
+
console.error(`Missing ${providerLabel} API key. Enter it now to continue.`);
|
|
194
|
+
const apiKey = await promptRequiredApiKey(providerLabel);
|
|
167
195
|
config = {
|
|
168
196
|
...config,
|
|
169
197
|
...(config.provider === "openrouter"
|
|
@@ -171,6 +199,7 @@ const ensureConfigured = async () => {
|
|
|
171
199
|
: { nvidiaApiKey: apiKey }),
|
|
172
200
|
};
|
|
173
201
|
changed = true;
|
|
202
|
+
console.error(`Saved ${providerLabel} API key. Continuing launch.`);
|
|
174
203
|
}
|
|
175
204
|
if (!activeModel) {
|
|
176
205
|
const [first] = await getRecommendedModels(config.provider, getActiveApiKey(config));
|
|
@@ -191,16 +220,13 @@ const runAuth = async () => {
|
|
|
191
220
|
const config = await loadConfig();
|
|
192
221
|
const providerLabel = getProviderLabel(config.provider);
|
|
193
222
|
const currentApiKey = getActiveApiKey(config);
|
|
194
|
-
const apiKey =
|
|
195
|
-
? `${providerLabel} API key (leave blank to keep current): `
|
|
196
|
-
:
|
|
223
|
+
const apiKey = currentApiKey
|
|
224
|
+
? await question(`${providerLabel} API key (leave blank to keep current): `)
|
|
225
|
+
: await promptRequiredApiKey(providerLabel);
|
|
197
226
|
if (!apiKey && currentApiKey) {
|
|
198
227
|
console.log(`Kept existing ${providerLabel} API key.`);
|
|
199
228
|
return;
|
|
200
229
|
}
|
|
201
|
-
if (!apiKey) {
|
|
202
|
-
throw new Error(`${providerLabel} API key is required.`);
|
|
203
|
-
}
|
|
204
230
|
await saveConfig({
|
|
205
231
|
...config,
|
|
206
232
|
...(config.provider === "openrouter"
|
|
@@ -565,6 +591,70 @@ const ensurePersistentClaudeRouting = async () => {
|
|
|
565
591
|
await writeExecutableTextFile(wrapperPath, wrapperContents);
|
|
566
592
|
return "installed";
|
|
567
593
|
};
|
|
594
|
+
const findExistingCodexNativeInDirectory = async (directory) => {
|
|
595
|
+
for (const name of getCodexNativeNames()) {
|
|
596
|
+
const candidate = path.join(directory, name);
|
|
597
|
+
if (await isExecutable(candidate)) {
|
|
598
|
+
return candidate;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
};
|
|
603
|
+
const resolvePersistentCodexCommand = async () => {
|
|
604
|
+
for (const name of getCodexCommandNames()) {
|
|
605
|
+
const found = await findExecutableInPath(name);
|
|
606
|
+
if (found) {
|
|
607
|
+
return found;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
};
|
|
612
|
+
const getCodexWrapperInstallPaths = async (codexCommandPath) => {
|
|
613
|
+
const directory = path.dirname(codexCommandPath);
|
|
614
|
+
const existingNative = await findExistingCodexNativeInDirectory(directory);
|
|
615
|
+
if (existingNative) {
|
|
616
|
+
return { wrapperPath: codexCommandPath, nativePath: existingNative };
|
|
617
|
+
}
|
|
618
|
+
if (isWindows && path.extname(codexCommandPath).toLowerCase() === ".exe") {
|
|
619
|
+
return {
|
|
620
|
+
wrapperPath: path.join(directory, "codex.cmd"),
|
|
621
|
+
nativePath: path.join(directory, "codex-native.exe"),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const extension = path.extname(codexCommandPath);
|
|
625
|
+
return {
|
|
626
|
+
wrapperPath: codexCommandPath,
|
|
627
|
+
nativePath: path.join(directory, `codex-native${extension}`),
|
|
628
|
+
};
|
|
629
|
+
};
|
|
630
|
+
const ensurePersistentCodexRouting = async () => {
|
|
631
|
+
const codexCommandPath = await resolvePersistentCodexCommand();
|
|
632
|
+
if (!codexCommandPath) {
|
|
633
|
+
return "skipped";
|
|
634
|
+
}
|
|
635
|
+
if (await isManagedClaudeWrapper(codexCommandPath)) {
|
|
636
|
+
const currentWrapper = await readIfExists(codexCommandPath);
|
|
637
|
+
const wrapperContents = renderCodexWrapper();
|
|
638
|
+
if (currentWrapper === wrapperContents) {
|
|
639
|
+
return "already";
|
|
640
|
+
}
|
|
641
|
+
await writeExecutableTextFile(codexCommandPath, wrapperContents);
|
|
642
|
+
return "updated";
|
|
643
|
+
}
|
|
644
|
+
const { wrapperPath, nativePath } = await getCodexWrapperInstallPaths(codexCommandPath);
|
|
645
|
+
const wrapperContents = renderCodexWrapper();
|
|
646
|
+
if (!(await pathExists(nativePath))) {
|
|
647
|
+
await fs.rename(codexCommandPath, nativePath);
|
|
648
|
+
}
|
|
649
|
+
else if (codexCommandPath !== wrapperPath && await pathExists(wrapperPath)) {
|
|
650
|
+
await fs.rm(wrapperPath, { force: true });
|
|
651
|
+
}
|
|
652
|
+
else if (codexCommandPath === wrapperPath) {
|
|
653
|
+
await fs.rm(wrapperPath, { force: true });
|
|
654
|
+
}
|
|
655
|
+
await writeExecutableTextFile(wrapperPath, wrapperContents);
|
|
656
|
+
return "installed";
|
|
657
|
+
};
|
|
568
658
|
const resolveClaudeBinary = async () => {
|
|
569
659
|
for (const name of getClaudeNativeNames()) {
|
|
570
660
|
const nativeInPath = await findExecutableInPath(name);
|
|
@@ -635,6 +725,39 @@ const resolveOpenClawBinary = async () => {
|
|
|
635
725
|
}
|
|
636
726
|
throw new Error("Unable to locate OpenClaw binary. Install OpenClaw first with `npm install -g openclaw@latest`.");
|
|
637
727
|
};
|
|
728
|
+
const resolveCodexBinary = async () => {
|
|
729
|
+
for (const name of getCodexNativeNames()) {
|
|
730
|
+
const nativeInPath = await findExecutableInPath(name);
|
|
731
|
+
if (nativeInPath) {
|
|
732
|
+
return nativeInPath;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const homeBinCandidates = isWindows
|
|
736
|
+
? [
|
|
737
|
+
path.join(os.homedir(), ".local", "bin", "codex-native.exe"),
|
|
738
|
+
path.join(os.homedir(), ".local", "bin", "codex-native"),
|
|
739
|
+
path.join(os.homedir(), ".local", "bin", "codex.exe"),
|
|
740
|
+
path.join(os.homedir(), ".local", "bin", "codex.cmd"),
|
|
741
|
+
path.join(os.homedir(), ".local", "bin", "codex.bat"),
|
|
742
|
+
path.join(os.homedir(), ".local", "bin", "codex"),
|
|
743
|
+
]
|
|
744
|
+
: [
|
|
745
|
+
path.join(os.homedir(), ".local", "bin", "codex-native"),
|
|
746
|
+
path.join(os.homedir(), ".local", "bin", "codex"),
|
|
747
|
+
];
|
|
748
|
+
for (const candidate of homeBinCandidates) {
|
|
749
|
+
if (await isExecutable(candidate)) {
|
|
750
|
+
return candidate;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const name of getCodexCommandNames()) {
|
|
754
|
+
const codexInPath = await findExecutableInPath(name);
|
|
755
|
+
if (codexInPath && !(await isManagedClaudeWrapper(codexInPath))) {
|
|
756
|
+
return codexInPath;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
throw new Error("Unable to locate Codex binary. Install Codex first with `npm install -g @openai/codex`.");
|
|
760
|
+
};
|
|
638
761
|
const findExecutableInPath = async (name) => {
|
|
639
762
|
const pathEntries = (process.env.PATH || "").split(path.delimiter);
|
|
640
763
|
for (const entry of pathEntries) {
|
|
@@ -776,6 +899,45 @@ const runLaunchOpenClaw = async (args) => {
|
|
|
776
899
|
child.on("error", reject);
|
|
777
900
|
});
|
|
778
901
|
};
|
|
902
|
+
const runLaunchCodex = async (args) => {
|
|
903
|
+
const config = await ensureConfigured();
|
|
904
|
+
const routingStatus = await ensurePersistentCodexRouting().catch(() => "skipped");
|
|
905
|
+
const activeModel = getActiveModel(config);
|
|
906
|
+
const codexBinary = await resolveCodexBinary();
|
|
907
|
+
await ensureProxyRunning(config);
|
|
908
|
+
const proxyBaseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
909
|
+
const env = {
|
|
910
|
+
...process.env,
|
|
911
|
+
NVICODE_PROXY_TOKEN: config.proxyToken,
|
|
912
|
+
};
|
|
913
|
+
const hasModelFlag = args.includes("--model") || args.includes("-m");
|
|
914
|
+
const codexArgs = [
|
|
915
|
+
"-c", `model_provider="nvicode"`,
|
|
916
|
+
"-c", `model_providers.nvicode.name="nvicode proxy"`,
|
|
917
|
+
"-c", `model_providers.nvicode.base_url="${proxyBaseUrl}"`,
|
|
918
|
+
"-c", `model_providers.nvicode.env_key="NVICODE_PROXY_TOKEN"`,
|
|
919
|
+
"-c", `model_providers.nvicode.wire_api="responses"`,
|
|
920
|
+
"-c", `model_providers.nvicode.supports_websockets=false`,
|
|
921
|
+
...(hasModelFlag ? [] : ["--model", activeModel]),
|
|
922
|
+
...args,
|
|
923
|
+
];
|
|
924
|
+
if (process.stdout.isTTY &&
|
|
925
|
+
(routingStatus === "installed" || routingStatus === "updated")) {
|
|
926
|
+
console.error("nvicode installed persistent `codex` routing. Future plain `codex` launches will use the selected nvicode provider and model.");
|
|
927
|
+
}
|
|
928
|
+
const child = spawnClaudeProcess(codexBinary, codexArgs, env);
|
|
929
|
+
await new Promise((resolve, reject) => {
|
|
930
|
+
child.on("exit", (code, signal) => {
|
|
931
|
+
if (signal) {
|
|
932
|
+
reject(new Error(`Codex exited with signal ${signal}`));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
process.exitCode = code ?? 0;
|
|
936
|
+
resolve();
|
|
937
|
+
});
|
|
938
|
+
child.on("error", reject);
|
|
939
|
+
});
|
|
940
|
+
};
|
|
779
941
|
const runLaunchClaude = async (args) => {
|
|
780
942
|
const config = await ensureConfigured();
|
|
781
943
|
const routingStatus = await ensurePersistentClaudeRouting().catch(() => "skipped");
|
|
@@ -830,15 +992,12 @@ const runLaunchClaude = async (args) => {
|
|
|
830
992
|
};
|
|
831
993
|
const runServe = async () => {
|
|
832
994
|
const config = await ensureConfigured();
|
|
833
|
-
if (config.provider !== "nvidia") {
|
|
834
|
-
throw new Error("`nvicode serve` is only available for the NVIDIA provider.");
|
|
835
|
-
}
|
|
836
995
|
const server = createProxyServer(config);
|
|
837
996
|
await new Promise((resolve, reject) => {
|
|
838
997
|
server.once("error", reject);
|
|
839
998
|
server.listen(config.proxyPort, "127.0.0.1", () => resolve());
|
|
840
999
|
});
|
|
841
|
-
console.error(`nvicode proxy listening on http://127.0.0.1:${config.proxyPort} using ${config.
|
|
1000
|
+
console.error(`nvicode proxy listening on http://127.0.0.1:${config.proxyPort} using ${getActiveModel(config)} (${config.provider})`);
|
|
842
1001
|
const shutdown = () => {
|
|
843
1002
|
server.close(() => process.exit(0));
|
|
844
1003
|
};
|
|
@@ -895,7 +1054,11 @@ const main = async () => {
|
|
|
895
1054
|
await runLaunchOpenClaw(rest.slice(1));
|
|
896
1055
|
return;
|
|
897
1056
|
}
|
|
898
|
-
|
|
1057
|
+
if (rest[0] === "codex") {
|
|
1058
|
+
await runLaunchCodex(rest.slice(1));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
throw new Error("Supported launch targets are `claude`, `openclaw`, and `codex`.");
|
|
899
1062
|
}
|
|
900
1063
|
throw new Error(`Unknown command: ${command}`);
|
|
901
1064
|
};
|
package/dist/proxy.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { appendUsageRecord, buildUsageRecord, getPricingSnapshot, } from "./usage.js";
|
|
4
|
+
import { getActiveApiKey, getActiveModel } from "./config.js";
|
|
4
5
|
const NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions";
|
|
6
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
5
7
|
const DEFAULT_RETRY_DELAY_MS = 2_000;
|
|
6
8
|
const MAX_NVIDIA_RETRIES = 3;
|
|
7
9
|
const sleep = async (ms) => {
|
|
@@ -448,6 +450,234 @@ const callNvidia = async (config, scheduleRequest, payload) => {
|
|
|
448
450
|
upstream: await scheduleRequest(invoke),
|
|
449
451
|
};
|
|
450
452
|
};
|
|
453
|
+
const isInputMessage = (item) => "role" in item &&
|
|
454
|
+
(!("type" in item) || item.type === "message");
|
|
455
|
+
const translateResponsesInputToMessages = (input) => {
|
|
456
|
+
const messages = [];
|
|
457
|
+
let pendingToolCalls = [];
|
|
458
|
+
const flushPendingToolCalls = () => {
|
|
459
|
+
if (pendingToolCalls.length > 0) {
|
|
460
|
+
messages.push({
|
|
461
|
+
role: "assistant",
|
|
462
|
+
content: null,
|
|
463
|
+
tool_calls: pendingToolCalls,
|
|
464
|
+
});
|
|
465
|
+
pendingToolCalls = [];
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
for (const item of input) {
|
|
469
|
+
if (isInputMessage(item)) {
|
|
470
|
+
flushPendingToolCalls();
|
|
471
|
+
const role = item.role === "developer" ? "system" : item.role;
|
|
472
|
+
if (typeof item.content === "string") {
|
|
473
|
+
messages.push({ role, content: item.content });
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
const parts = item.content.map((part) => {
|
|
477
|
+
if (part.type === "input_image" && part.image_url) {
|
|
478
|
+
return { type: "image_url", image_url: { url: part.image_url } };
|
|
479
|
+
}
|
|
480
|
+
return { type: "text", text: part.text || "" };
|
|
481
|
+
});
|
|
482
|
+
messages.push({ role, content: parts });
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (item.type === "function_call") {
|
|
487
|
+
pendingToolCalls.push({
|
|
488
|
+
id: item.call_id,
|
|
489
|
+
type: "function",
|
|
490
|
+
function: { name: item.name, arguments: item.arguments },
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (item.type === "function_call_output") {
|
|
495
|
+
flushPendingToolCalls();
|
|
496
|
+
messages.push({
|
|
497
|
+
role: "tool",
|
|
498
|
+
tool_call_id: item.call_id,
|
|
499
|
+
content: item.output,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
flushPendingToolCalls();
|
|
504
|
+
return messages;
|
|
505
|
+
};
|
|
506
|
+
const translateResponsesTools = (tools) => {
|
|
507
|
+
if (!tools || tools.length === 0)
|
|
508
|
+
return undefined;
|
|
509
|
+
return tools
|
|
510
|
+
.filter((t) => t.type === "function")
|
|
511
|
+
.map((t) => ({
|
|
512
|
+
type: "function",
|
|
513
|
+
function: {
|
|
514
|
+
name: t.name,
|
|
515
|
+
description: t.description,
|
|
516
|
+
parameters: t.parameters ?? { type: "object", properties: {} },
|
|
517
|
+
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
518
|
+
},
|
|
519
|
+
}));
|
|
520
|
+
};
|
|
521
|
+
const buildResponsesApiResponse = (upstream, requestModel) => {
|
|
522
|
+
const choice = upstream.choices?.[0];
|
|
523
|
+
const output = [];
|
|
524
|
+
const messageContent = choice?.message?.content;
|
|
525
|
+
const text = typeof messageContent === "string"
|
|
526
|
+
? messageContent
|
|
527
|
+
: Array.isArray(messageContent)
|
|
528
|
+
? messageContent
|
|
529
|
+
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
|
530
|
+
.filter(Boolean)
|
|
531
|
+
.join("\n")
|
|
532
|
+
: "";
|
|
533
|
+
if (text.length > 0 ||
|
|
534
|
+
(!choice?.message?.tool_calls?.length &&
|
|
535
|
+
!choice?.message?.reasoning?.trim())) {
|
|
536
|
+
const finalText = text.length > 0
|
|
537
|
+
? text
|
|
538
|
+
: choice?.message?.reasoning?.trim() || "";
|
|
539
|
+
output.push({
|
|
540
|
+
type: "message",
|
|
541
|
+
id: `msg_${randomUUID()}`,
|
|
542
|
+
role: "assistant",
|
|
543
|
+
status: "completed",
|
|
544
|
+
content: [{ type: "output_text", text: finalText }],
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
for (const tc of choice?.message?.tool_calls ?? []) {
|
|
548
|
+
const name = tc.function?.name;
|
|
549
|
+
if (!name)
|
|
550
|
+
continue;
|
|
551
|
+
output.push({
|
|
552
|
+
type: "function_call",
|
|
553
|
+
id: `fc_${randomUUID()}`,
|
|
554
|
+
call_id: tc.id || `call_${randomUUID()}`,
|
|
555
|
+
name,
|
|
556
|
+
arguments: tc.function?.arguments || "{}",
|
|
557
|
+
status: "completed",
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (output.length === 0) {
|
|
561
|
+
output.push({
|
|
562
|
+
type: "message",
|
|
563
|
+
id: `msg_${randomUUID()}`,
|
|
564
|
+
role: "assistant",
|
|
565
|
+
status: "completed",
|
|
566
|
+
content: [{ type: "output_text", text: "" }],
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
id: upstream.id || `resp_${randomUUID()}`,
|
|
571
|
+
object: "response",
|
|
572
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
573
|
+
status: "completed",
|
|
574
|
+
model: requestModel,
|
|
575
|
+
output,
|
|
576
|
+
usage: {
|
|
577
|
+
input_tokens: upstream.usage?.prompt_tokens ?? 0,
|
|
578
|
+
output_tokens: upstream.usage?.completion_tokens ?? 0,
|
|
579
|
+
total_tokens: (upstream.usage?.prompt_tokens ?? 0) +
|
|
580
|
+
(upstream.usage?.completion_tokens ?? 0),
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
const writeResponsesSseEvent = (response, type, payload) => {
|
|
585
|
+
response.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
586
|
+
};
|
|
587
|
+
const streamResponsesApiResponse = (response, apiResponse) => {
|
|
588
|
+
response.writeHead(200, {
|
|
589
|
+
"Content-Type": "text/event-stream",
|
|
590
|
+
"Cache-Control": "no-cache",
|
|
591
|
+
Connection: "keep-alive",
|
|
592
|
+
});
|
|
593
|
+
writeResponsesSseEvent(response, "response.created", {
|
|
594
|
+
response: { ...apiResponse, output: [], status: "in_progress" },
|
|
595
|
+
});
|
|
596
|
+
for (let i = 0; i < apiResponse.output.length; i++) {
|
|
597
|
+
const item = apiResponse.output[i];
|
|
598
|
+
if (item.type === "message") {
|
|
599
|
+
writeResponsesSseEvent(response, "response.output_item.added", {
|
|
600
|
+
output_index: i,
|
|
601
|
+
item: { ...item, content: [], status: "in_progress" },
|
|
602
|
+
});
|
|
603
|
+
for (let j = 0; j < item.content.length; j++) {
|
|
604
|
+
const part = item.content[j];
|
|
605
|
+
writeResponsesSseEvent(response, "response.content_part.added", {
|
|
606
|
+
output_index: i,
|
|
607
|
+
content_index: j,
|
|
608
|
+
part: { type: "output_text", text: "" },
|
|
609
|
+
});
|
|
610
|
+
for (const chunk of chunkText(part.text)) {
|
|
611
|
+
writeResponsesSseEvent(response, "response.output_text.delta", {
|
|
612
|
+
output_index: i,
|
|
613
|
+
content_index: j,
|
|
614
|
+
delta: chunk,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
writeResponsesSseEvent(response, "response.output_text.done", {
|
|
618
|
+
output_index: i,
|
|
619
|
+
content_index: j,
|
|
620
|
+
text: part.text,
|
|
621
|
+
});
|
|
622
|
+
writeResponsesSseEvent(response, "response.content_part.done", {
|
|
623
|
+
output_index: i,
|
|
624
|
+
content_index: j,
|
|
625
|
+
part,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
writeResponsesSseEvent(response, "response.output_item.done", {
|
|
629
|
+
output_index: i,
|
|
630
|
+
item: { ...item, status: "completed" },
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
else if (item.type === "function_call") {
|
|
634
|
+
writeResponsesSseEvent(response, "response.output_item.added", {
|
|
635
|
+
output_index: i,
|
|
636
|
+
item: { ...item, arguments: "", status: "in_progress" },
|
|
637
|
+
});
|
|
638
|
+
writeResponsesSseEvent(response, "response.function_call_arguments.delta", { output_index: i, delta: item.arguments });
|
|
639
|
+
writeResponsesSseEvent(response, "response.function_call_arguments.done", { output_index: i, arguments: item.arguments });
|
|
640
|
+
writeResponsesSseEvent(response, "response.output_item.done", {
|
|
641
|
+
output_index: i,
|
|
642
|
+
item: { ...item, status: "completed" },
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
writeResponsesSseEvent(response, "response.completed", {
|
|
647
|
+
response: apiResponse,
|
|
648
|
+
});
|
|
649
|
+
response.end();
|
|
650
|
+
};
|
|
651
|
+
const getUpstreamChatUrl = (config) => config.provider === "openrouter" ? OPENROUTER_URL : NVIDIA_URL;
|
|
652
|
+
const callChatCompletions = async (config, scheduleRequest, body) => {
|
|
653
|
+
const upstreamUrl = getUpstreamChatUrl(config);
|
|
654
|
+
const apiKey = getActiveApiKey(config);
|
|
655
|
+
const invoke = async () => {
|
|
656
|
+
for (let attempt = 0; attempt <= MAX_NVIDIA_RETRIES; attempt += 1) {
|
|
657
|
+
const resp = await fetch(upstreamUrl, {
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: {
|
|
660
|
+
Authorization: `Bearer ${apiKey}`,
|
|
661
|
+
Accept: "application/json",
|
|
662
|
+
"Content-Type": "application/json",
|
|
663
|
+
},
|
|
664
|
+
body: JSON.stringify(body),
|
|
665
|
+
});
|
|
666
|
+
const raw = await resp.text();
|
|
667
|
+
if (resp.ok)
|
|
668
|
+
return JSON.parse(raw);
|
|
669
|
+
if (resp.status === 429 && attempt < MAX_NVIDIA_RETRIES) {
|
|
670
|
+
const retryMs = parseRetryAfterMs(resp.headers.get("retry-after")) ||
|
|
671
|
+
DEFAULT_RETRY_DELAY_MS * 2 ** attempt;
|
|
672
|
+
await sleep(retryMs);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
throw new Error(`Upstream API HTTP ${resp.status}: ${raw}`);
|
|
676
|
+
}
|
|
677
|
+
throw new Error("Upstream API retry loop exhausted unexpectedly.");
|
|
678
|
+
};
|
|
679
|
+
return scheduleRequest(invoke);
|
|
680
|
+
};
|
|
451
681
|
export const createProxyServer = (config) => {
|
|
452
682
|
const scheduleNvidiaRequest = createRequestScheduler(config.maxRequestsPerMinute);
|
|
453
683
|
return createServer(async (request, response) => {
|
|
@@ -632,6 +862,43 @@ export const createProxyServer = (config) => {
|
|
|
632
862
|
throw error;
|
|
633
863
|
}
|
|
634
864
|
}
|
|
865
|
+
if (request.method === "POST" && url.pathname === "/v1/responses") {
|
|
866
|
+
const rawBody = await readRequestBody(request);
|
|
867
|
+
const payload = JSON.parse(rawBody);
|
|
868
|
+
const targetModel = payload.model || getActiveModel(config);
|
|
869
|
+
const chatBody = {
|
|
870
|
+
model: targetModel,
|
|
871
|
+
messages: translateResponsesInputToMessages(payload.input),
|
|
872
|
+
max_tokens: payload.max_output_tokens ?? 16_384,
|
|
873
|
+
stream: false,
|
|
874
|
+
};
|
|
875
|
+
if (typeof payload.temperature === "number") {
|
|
876
|
+
chatBody.temperature = payload.temperature;
|
|
877
|
+
}
|
|
878
|
+
if (typeof payload.top_p === "number") {
|
|
879
|
+
chatBody.top_p = payload.top_p;
|
|
880
|
+
}
|
|
881
|
+
const tools = translateResponsesTools(payload.tools);
|
|
882
|
+
if (tools)
|
|
883
|
+
chatBody.tools = tools;
|
|
884
|
+
try {
|
|
885
|
+
const upstream = await callChatCompletions(config, scheduleNvidiaRequest, chatBody);
|
|
886
|
+
const apiResponse = buildResponsesApiResponse(upstream, targetModel);
|
|
887
|
+
if (payload.stream === false) {
|
|
888
|
+
sendJson(response, 200, apiResponse);
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
streamResponsesApiResponse(response, apiResponse);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
896
|
+
sendJson(response, 502, {
|
|
897
|
+
error: { message: msg, type: "upstream_error", code: "upstream_error" },
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
635
902
|
sendAnthropicError(response, 404, "not_found_error", `Unsupported route: ${request.method || "GET"} ${url.pathname}`);
|
|
636
903
|
}
|
|
637
904
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nvicode",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.18",
|
|
4
|
+
"description": "Route Claude Code, Codex CLI, and OpenClaw through NVIDIA or OpenRouter with one setup.",
|
|
5
5
|
"author": "Dinesh Potla",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"openclaw",
|
|
10
|
+
"openrouter",
|
|
8
11
|
"nvidia",
|
|
9
12
|
"kimi",
|
|
10
13
|
"qwen",
|