@rerouter/helper 0.0.0-stage → 0.1.1
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 +119 -2
- package/package.json +36 -4
- package/src/cli.js +266 -0
- package/src/lib/api.js +35 -0
- package/src/lib/bridge-server.js +217 -0
- package/src/lib/env.js +32 -0
- package/src/lib/files.js +85 -0
- package/src/lib/responses-bridge.js +639 -0
- package/src/lib/targets.js +390 -0
- package/src/responses-bridge.test.js +580 -0
- package/src/targets.test.js +215 -0
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rerouter/helper
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect coding agents (Claude, Codex, Cursor, opencode, Continue) to Rerouter in seconds.
|
|
4
|
+
|
|
5
|
+
One command to configure your favorite AI coding tool to use Rerouter as the LLM gateway — with automatic backup, preview-first, and zero API keys written to disk.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @rerouter/helper
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install globally:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @rerouter/helper
|
|
17
|
+
rerouter-helper
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
Set your Rerouter API key (get one from the Rerouter dashboard → API Keys):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# POSIX (bash/zsh)
|
|
26
|
+
export REROUTER_API_KEY=sk-rr-...
|
|
27
|
+
|
|
28
|
+
# PowerShell
|
|
29
|
+
$env:REROUTER_API_KEY = "sk-rr-..."
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run the interactive wizard:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx @rerouter/helper
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or configure directly:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @rerouter/helper configure --target claude --model anthropic/claude-sonnet-4 --write
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|--------|-------------|
|
|
48
|
+
| `npx @rerouter/helper` | Interactive wizard |
|
|
49
|
+
| `npx @rerouter/helper models [--search X]` | List models from catalog (auth required) |
|
|
50
|
+
| `npx @rerouter/helper configure --target T --model ID [--small-model ID] [--port N] [--write [--yes]]` | Configure a target |
|
|
51
|
+
| `npx @rerouter/helper check-key` | Key info without inference/billing |
|
|
52
|
+
| `npx @rerouter/helper targets` | List configuration targets |
|
|
53
|
+
| `npx @rerouter/helper bridge [--port N] [--host H]` | Local Responses API bridge (Codex) → Rerouter |
|
|
54
|
+
|
|
55
|
+
## Supported targets
|
|
56
|
+
|
|
57
|
+
| Target | Type | What it does |
|
|
58
|
+
|--------|------|-------------|
|
|
59
|
+
| `claude` | Automatic | Merges `~/.claude/settings.json` with env vars |
|
|
60
|
+
| `codex` | Automatic | Writes `~/.codex/config.toml` for local bridge |
|
|
61
|
+
| `opencode` | Automatic | Merges `~/.config/opencode/opencode.json` |
|
|
62
|
+
| `omp` | Automatic | Writes `~/.omp/agent/models.yml` |
|
|
63
|
+
| `continue` | Automatic | Writes `~/.continue/config.yaml` + `.env` placeholder |
|
|
64
|
+
| `openai` | Snippet | Prints shell env exports |
|
|
65
|
+
| `anthropic` | Snippet | Prints shell env exports |
|
|
66
|
+
| `cursor` | Guide | Step-by-step UI instructions |
|
|
67
|
+
| `vscode` | Guide | Step-by-step UI instructions |
|
|
68
|
+
| `cline` | Guide | Step-by-step UI instructions |
|
|
69
|
+
| `roo` | Guide | Step-by-step UI instructions |
|
|
70
|
+
|
|
71
|
+
## Codex (Responses API bridge)
|
|
72
|
+
|
|
73
|
+
Codex CLI speaks the Responses API (`POST /v1/responses`). Rerouter speaks Chat Completions. Run the local bridge to translate:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Terminal 1: start bridge
|
|
77
|
+
npx @rerouter/helper bridge
|
|
78
|
+
|
|
79
|
+
# Terminal 2: configure Codex
|
|
80
|
+
npx @rerouter/helper configure --target codex --model your-model --write
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The bridge runs on `127.0.0.1:8787` by default. Codex reads `env_key` from `REROUTER_API_KEY`.
|
|
84
|
+
|
|
85
|
+
## Safety
|
|
86
|
+
|
|
87
|
+
- **Preview first.** Without `--write`, every command is read-only.
|
|
88
|
+
- **Timestamp backup.** Any existing config is backed up before writing.
|
|
89
|
+
- **Key never written.** Your API key is only read from `REROUTER_API_KEY` env — never printed, never written to any file.
|
|
90
|
+
- **Ambiguous configs → manual merge.** If a config file is broken or ambiguous, the helper stops and shows you what to add manually.
|
|
91
|
+
|
|
92
|
+
## Environment variables
|
|
93
|
+
|
|
94
|
+
| Variable | Description |
|
|
95
|
+
|----------|-------------|
|
|
96
|
+
| `REROUTER_API_KEY` | Your Rerouter API key (`sk-rr-...`). Required for `models`, `configure`, `check-key`. |
|
|
97
|
+
| `REROUTER_BASE_URL` | Override base URL (default: `https://api.rerouter.id`). |
|
|
98
|
+
|
|
99
|
+
## Examples
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# List all models
|
|
103
|
+
npx @rerouter/helper models
|
|
104
|
+
|
|
105
|
+
# Search for Claude models
|
|
106
|
+
npx @rerouter/helper models --search claude
|
|
107
|
+
|
|
108
|
+
# Configure Claude with a small/fast model for background tasks
|
|
109
|
+
npx @rerouter/helper configure --target claude --model anthropic/claude-sonnet-4 --small-model anthropic/claude-haiku-4 --write
|
|
110
|
+
|
|
111
|
+
# Preview Cursor setup (no changes)
|
|
112
|
+
npx @rerouter/helper configure --target cursor --model openai/gpt-5
|
|
113
|
+
|
|
114
|
+
# Get shell exports for any OpenAI-compatible tool
|
|
115
|
+
npx @rerouter/helper configure --target openai --model openai/gpt-5
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rerouter/helper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Connect coding agents (Claude, Codex, Cursor, opencode, Continue) to Rerouter in seconds",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rerouter-helper": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/cli.js",
|
|
17
|
+
"test": "node --test src/*.test.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"yaml": "^2.6.1"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"rerouter",
|
|
25
|
+
"llm",
|
|
26
|
+
"gateway",
|
|
27
|
+
"claude",
|
|
28
|
+
"codex",
|
|
29
|
+
"cursor",
|
|
30
|
+
"opencode",
|
|
31
|
+
"continue",
|
|
32
|
+
"ai",
|
|
33
|
+
"coding-agent"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { keyInfo, listModels } from "./lib/api.js";
|
|
5
|
+
import { DEFAULT_BRIDGE_PORT, startBridge } from "./lib/bridge-server.js";
|
|
6
|
+
import { MissingKeyError, apiKey, baseUrl } from "./lib/env.js";
|
|
7
|
+
import {
|
|
8
|
+
ALL_TARGETS,
|
|
9
|
+
AUTOMATIC_TARGETS,
|
|
10
|
+
GUIDE_TARGETS,
|
|
11
|
+
SNIPPET_TARGETS,
|
|
12
|
+
applyPlan,
|
|
13
|
+
guide,
|
|
14
|
+
pickSmallModel,
|
|
15
|
+
planTarget,
|
|
16
|
+
snippet,
|
|
17
|
+
} from "./lib/targets.js";
|
|
18
|
+
|
|
19
|
+
const VERSION = "0.1.0";
|
|
20
|
+
|
|
21
|
+
const HELP = `Rerouter helper — connect coding agents to Rerouter.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
npx @rerouter/helper Interactive wizard
|
|
25
|
+
npx @rerouter/helper models [--search X] List models from catalog (auth)
|
|
26
|
+
npx @rerouter/helper configure --target T --model ID [--small-model ID] [--port N] [--write [--yes]]
|
|
27
|
+
npx @rerouter/helper check-key Key info without inference/billing
|
|
28
|
+
npx @rerouter/helper targets List configuration targets
|
|
29
|
+
npx @rerouter/helper bridge [--port N] [--host H]
|
|
30
|
+
Local Responses API bridge (Codex) → Rerouter
|
|
31
|
+
npx @rerouter/helper --version | --help
|
|
32
|
+
|
|
33
|
+
Automatic targets (config file merge): ${AUTOMATIC_TARGETS.join(", ")}
|
|
34
|
+
Shell snippet targets: ${SNIPPET_TARGETS.join(", ")}
|
|
35
|
+
UI guide targets: ${GUIDE_TARGETS.join(", ")}
|
|
36
|
+
|
|
37
|
+
Env:
|
|
38
|
+
REROUTER_API_KEY Rerouter key (sk-rr-...). Never printed or written to any file.
|
|
39
|
+
REROUTER_BASE_URL Override base URL (default: https://api.rerouter.id).
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
function fail(err) {
|
|
43
|
+
console.error(`error: ${err.message}`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cmdModels(values) {
|
|
48
|
+
const models = await listModels();
|
|
49
|
+
const needle = values.search?.toLowerCase();
|
|
50
|
+
const rows = models
|
|
51
|
+
.map((m) => String(m?.id ?? ""))
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.filter((id) => !needle || id.toLowerCase().includes(needle))
|
|
54
|
+
.sort();
|
|
55
|
+
if (!rows.length) {
|
|
56
|
+
console.log(needle ? `No models match "${values.search}".` : "Catalog is empty.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const id of rows) console.log(id);
|
|
60
|
+
console.log(`\n${rows.length} models.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function cmdCheckKey() {
|
|
64
|
+
const info = await keyInfo();
|
|
65
|
+
const lines = [
|
|
66
|
+
`key: ${info.prefix ?? "????"}…`,
|
|
67
|
+
`status: ${info.status ?? "unknown"}`,
|
|
68
|
+
];
|
|
69
|
+
if (info.name) lines.push(`name: ${info.name}`);
|
|
70
|
+
if (info.expires_at) lines.push(`expires: ${info.expires_at}`);
|
|
71
|
+
if (Array.isArray(info.model_scope) && info.model_scope.length) {
|
|
72
|
+
lines.push(`model scope: ${info.model_scope.join(", ")}`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push("model scope: (all models)");
|
|
75
|
+
}
|
|
76
|
+
if (typeof info.has_balance === "boolean") lines.push(`balance: ${info.has_balance ? "ok" : "empty"}`);
|
|
77
|
+
if (info.rate_limits) lines.push(`rate limit: ${JSON.stringify(info.rate_limits)}`);
|
|
78
|
+
console.log(lines.join("\n"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cmdTargets() {
|
|
82
|
+
console.log("Automatic targets (helper merges config file + backup):");
|
|
83
|
+
for (const t of AUTOMATIC_TARGETS) console.log(` ${t}`);
|
|
84
|
+
console.log("\nShell snippet targets (printed, no files changed):");
|
|
85
|
+
for (const t of SNIPPET_TARGETS) console.log(` ${t}`);
|
|
86
|
+
console.log("\nUI guide targets (manual steps in IDE):");
|
|
87
|
+
for (const t of GUIDE_TARGETS) console.log(` ${t}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function confirm(question) {
|
|
91
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
92
|
+
try {
|
|
93
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
94
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
95
|
+
} finally {
|
|
96
|
+
rl.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function cmdConfigure(values) {
|
|
101
|
+
const target = values.target;
|
|
102
|
+
if (!target || !ALL_TARGETS.includes(target)) {
|
|
103
|
+
throw new Error(`--target is required and must be one of: ${ALL_TARGETS.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
const opts = { baseUrl: baseUrl(), model: values.model, smallModel: values["small-model"], port: parsePort(values.port) };
|
|
106
|
+
|
|
107
|
+
if (GUIDE_TARGETS.includes(target)) {
|
|
108
|
+
console.log(guide(target, opts));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (SNIPPET_TARGETS.includes(target)) {
|
|
112
|
+
console.log(snippet(target, opts));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Automatic target: fill small-model from catalog if key available.
|
|
117
|
+
if (target === "claude" && !opts.smallModel && apiKey()) {
|
|
118
|
+
try {
|
|
119
|
+
opts.smallModel = pickSmallModel(await listModels(), opts.model);
|
|
120
|
+
} catch {
|
|
121
|
+
/* catalog failed → fallback to main model */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const plan = planTarget(target, opts);
|
|
125
|
+
const content = await plan.build();
|
|
126
|
+
|
|
127
|
+
console.log(`# ${plan.description}`);
|
|
128
|
+
console.log(`# file: ${plan.file}\n`);
|
|
129
|
+
console.log(content);
|
|
130
|
+
for (const note of plan.notes ?? []) console.log(`# ${note}`);
|
|
131
|
+
|
|
132
|
+
if (!values.write) {
|
|
133
|
+
console.log("\nPreview only. Add --write to write (with automatic backup).");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!values.yes) {
|
|
137
|
+
const ok = await confirm(`Write changes to ${plan.file}?`);
|
|
138
|
+
if (!ok) {
|
|
139
|
+
console.log("Cancelled — no files changed.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const results = await applyPlan(plan, content);
|
|
144
|
+
for (const r of results) {
|
|
145
|
+
console.log(`\nwritten: ${r.file}`);
|
|
146
|
+
if (r.backupPath) console.log(`backup: ${r.backupPath}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parsePort(value) {
|
|
151
|
+
if (value == null) return undefined;
|
|
152
|
+
const port = Number(value);
|
|
153
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
154
|
+
throw new Error(`--port invalid: "${value}" (must be an integer 1-65535)`);
|
|
155
|
+
}
|
|
156
|
+
return port;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function cmdBridge(values) {
|
|
160
|
+
const port = parsePort(values.port) ?? DEFAULT_BRIDGE_PORT;
|
|
161
|
+
const host = values.host?.trim() || "127.0.0.1";
|
|
162
|
+
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
163
|
+
throw new Error(`--host "${host}" rejected — bridge must be loopback only (127.0.0.1).`);
|
|
164
|
+
}
|
|
165
|
+
const key = apiKey();
|
|
166
|
+
const bridge = await startBridge({ host, port, baseUrl: baseUrl(), apiKey: key });
|
|
167
|
+
|
|
168
|
+
console.log(`Responses API bridge active: ${bridge.url}`);
|
|
169
|
+
console.log(`Upstream gateway: ${baseUrl()}`);
|
|
170
|
+
if (!key) {
|
|
171
|
+
console.log("\nNote: REROUTER_API_KEY not set — clients must send their own Authorization.");
|
|
172
|
+
}
|
|
173
|
+
console.log("\nConnect Codex (one-time):");
|
|
174
|
+
console.log(` npx @rerouter/helper configure --target codex --model <ID>${port !== DEFAULT_BRIDGE_PORT ? ` --port ${port}` : ""} --write`);
|
|
175
|
+
console.log("\nPress Ctrl+C to stop.");
|
|
176
|
+
|
|
177
|
+
await new Promise((resolve) => {
|
|
178
|
+
const stop = () => {
|
|
179
|
+
void bridge.close().then(resolve, resolve);
|
|
180
|
+
};
|
|
181
|
+
process.once("SIGINT", stop);
|
|
182
|
+
process.once("SIGTERM", stop);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function wizard() {
|
|
187
|
+
console.log(`Rerouter helper v${VERSION} — base URL: ${baseUrl()}`);
|
|
188
|
+
if (!apiKey()) {
|
|
189
|
+
console.log("\nNote: REROUTER_API_KEY not set (model listing disabled).\n");
|
|
190
|
+
}
|
|
191
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
192
|
+
try {
|
|
193
|
+
console.log("\nSelect target:");
|
|
194
|
+
ALL_TARGETS.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
|
|
195
|
+
const pick = await rl.question(`\nTarget [1-${ALL_TARGETS.length}]: `);
|
|
196
|
+
const target = ALL_TARGETS[Number(pick.trim()) - 1];
|
|
197
|
+
if (!target) throw new Error("invalid selection");
|
|
198
|
+
|
|
199
|
+
let model;
|
|
200
|
+
if (apiKey()) {
|
|
201
|
+
try {
|
|
202
|
+
const models = (await listModels()).map((m) => String(m?.id ?? "")).filter(Boolean);
|
|
203
|
+
if (models.length) {
|
|
204
|
+
console.log(`\nAvailable models (first 10): ${models.slice(0, 10).join(", ")}${models.length > 10 ? ` … (+${models.length - 10})` : ""}`);
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
/* ignore — manual input */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
model = (await rl.question("Model ID: ")).trim();
|
|
211
|
+
if (!model) throw new Error("model is required");
|
|
212
|
+
|
|
213
|
+
const values = { target, model, write: true, yes: false };
|
|
214
|
+
rl.close();
|
|
215
|
+
await cmdConfigure(values);
|
|
216
|
+
} finally {
|
|
217
|
+
rl.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function main() {
|
|
222
|
+
const { values, positionals } = parseArgs({
|
|
223
|
+
allowPositionals: true,
|
|
224
|
+
options: {
|
|
225
|
+
search: { type: "string" },
|
|
226
|
+
target: { type: "string" },
|
|
227
|
+
model: { type: "string" },
|
|
228
|
+
"small-model": { type: "string" },
|
|
229
|
+
port: { type: "string" },
|
|
230
|
+
host: { type: "string" },
|
|
231
|
+
write: { type: "boolean", default: false },
|
|
232
|
+
yes: { type: "boolean", default: false },
|
|
233
|
+
help: { type: "boolean", short: "h", default: false },
|
|
234
|
+
version: { type: "boolean", short: "v", default: false },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const command = positionals[0];
|
|
239
|
+
if (values.help) return console.log(HELP);
|
|
240
|
+
if (values.version) return console.log(VERSION);
|
|
241
|
+
|
|
242
|
+
switch (command) {
|
|
243
|
+
case undefined:
|
|
244
|
+
return wizard();
|
|
245
|
+
case "models":
|
|
246
|
+
return cmdModels(values);
|
|
247
|
+
case "configure":
|
|
248
|
+
return cmdConfigure(values);
|
|
249
|
+
case "check-key":
|
|
250
|
+
return cmdCheckKey();
|
|
251
|
+
case "targets":
|
|
252
|
+
return cmdTargets();
|
|
253
|
+
case "bridge":
|
|
254
|
+
return cmdBridge(values);
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(`unknown command: "${command}". Run with --help.`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
main().catch((err) => {
|
|
261
|
+
if (err instanceof MissingKeyError) {
|
|
262
|
+
fail(err);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
fail(err);
|
|
266
|
+
});
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { baseUrl, requireApiKey } from "./env.js";
|
|
2
|
+
|
|
3
|
+
async function fetchJson(path, { timeoutMs = 15_000 } = {}) {
|
|
4
|
+
const key = requireApiKey();
|
|
5
|
+
const res = await fetch(`${baseUrl()}${path}`, {
|
|
6
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" },
|
|
7
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
8
|
+
});
|
|
9
|
+
const text = await res.text();
|
|
10
|
+
let body = null;
|
|
11
|
+
try {
|
|
12
|
+
body = text ? JSON.parse(text) : null;
|
|
13
|
+
} catch {
|
|
14
|
+
/* leave null — handled below */
|
|
15
|
+
}
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const message =
|
|
18
|
+
(body && typeof body.error === "string" && body.error) ||
|
|
19
|
+
body?.error?.message ||
|
|
20
|
+
`HTTP ${res.status}`;
|
|
21
|
+
throw new Error(`${path} failed: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** GET /v1/models — catalog remains auth (decision #1). */
|
|
27
|
+
export async function listModels() {
|
|
28
|
+
const body = await fetchJson("/v1/models");
|
|
29
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** GET /v1/key/info — zero-inference, no billing. */
|
|
33
|
+
export async function keyInfo() {
|
|
34
|
+
return fetchJson("/v1/key/info");
|
|
35
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
ResponsesBridgeError,
|
|
4
|
+
chatJsonToResponse,
|
|
5
|
+
responsesToChatRequest,
|
|
6
|
+
wrapResponsesStream,
|
|
7
|
+
} from "./responses-bridge.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Local Responses API → Chat Completions bridge.
|
|
11
|
+
*
|
|
12
|
+
* Codex CLI only speaks the Responses API (`POST /v1/responses`); the
|
|
13
|
+
* Rerouter gateway speaks Chat Completions. This bridge runs on localhost,
|
|
14
|
+
* translates both ways, and forwards auth as-is — billing, failover, and
|
|
15
|
+
* rate limiting stay at the gateway. No state, no local billing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_BRIDGE_PORT = 8787;
|
|
21
|
+
|
|
22
|
+
function sendJson(res, status, body, headers = {}) {
|
|
23
|
+
const payload = JSON.stringify(body);
|
|
24
|
+
res.writeHead(status, {
|
|
25
|
+
"content-type": "application/json; charset=utf-8",
|
|
26
|
+
"content-length": Buffer.byteLength(payload),
|
|
27
|
+
...headers,
|
|
28
|
+
});
|
|
29
|
+
res.end(payload);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sendError(res, status, message, type = "invalid_request_error", code = null) {
|
|
33
|
+
sendJson(res, status, { error: { type, message, code, param: null } });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readBody(req, limit = MAX_BODY_BYTES) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
for await (const chunk of req) {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > limit) throw new ResponsesBridgeError(413, "Body too large (>32MB).");
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveAuth(req, fallbackKey) {
|
|
48
|
+
const header = req.headers.authorization;
|
|
49
|
+
if (typeof header === "string" && header.trim()) return header;
|
|
50
|
+
if (fallbackKey) return `Bearer ${fallbackKey}`;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {string} [opts.host] default 127.0.0.1 — bridge must NOT be exposed.
|
|
57
|
+
* @param {number} [opts.port]
|
|
58
|
+
* @param {string} opts.baseUrl gateway base URL (no trailing slash)
|
|
59
|
+
* @param {string} [opts.apiKey] fallback if the client does not send Authorization
|
|
60
|
+
* @param {typeof fetch} [opts.fetchImpl] for testing
|
|
61
|
+
*/
|
|
62
|
+
export async function startBridge({ host = "127.0.0.1", port = DEFAULT_BRIDGE_PORT, baseUrl, apiKey, fetchImpl = fetch }) {
|
|
63
|
+
if (!baseUrl) throw new Error("startBridge: baseUrl is required.");
|
|
64
|
+
const chatUrl = `${baseUrl}/v1/chat/completions`;
|
|
65
|
+
const modelsUrl = `${baseUrl}/v1/models`;
|
|
66
|
+
|
|
67
|
+
const server = http.createServer((req, res) => {
|
|
68
|
+
void handle(req, res).catch((err) => {
|
|
69
|
+
if (res.headersSent) {
|
|
70
|
+
res.end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (err instanceof ResponsesBridgeError) {
|
|
74
|
+
sendError(res, err.status, err.message, err.type);
|
|
75
|
+
} else {
|
|
76
|
+
sendError(res, 500, `bridge internal error: ${err?.message ?? err}`, "server_error");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function handle(req, res) {
|
|
82
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
83
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
84
|
+
|
|
85
|
+
if (req.method === "GET" && (path === "/health" || path === "/")) {
|
|
86
|
+
sendJson(res, 200, { ok: true, bridge: "rerouter-responses", upstream: baseUrl });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (req.method === "GET" && path === "/v1/models") {
|
|
91
|
+
const auth = resolveAuth(req, apiKey);
|
|
92
|
+
if (!auth) {
|
|
93
|
+
sendError(res, 401, "Missing bearer token (set REROUTER_API_KEY or Authorization header).", "authentication_error");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const upstream = await fetchImpl(modelsUrl, { headers: { authorization: auth } });
|
|
97
|
+
const text = await upstream.text();
|
|
98
|
+
res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
|
|
99
|
+
res.end(text);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (req.method === "POST" && (path === "/v1/responses" || path === "/responses")) {
|
|
104
|
+
await handleResponses(req, res);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sendError(res, 404, `Unknown endpoint: ${req.method} ${path}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleResponses(req, res) {
|
|
112
|
+
const auth = resolveAuth(req, apiKey);
|
|
113
|
+
if (!auth) {
|
|
114
|
+
sendError(res, 401, "Missing bearer token (set REROUTER_API_KEY or Authorization header).", "authentication_error");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let body;
|
|
119
|
+
try {
|
|
120
|
+
body = JSON.parse(await readBody(req));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ResponsesBridgeError) throw err;
|
|
123
|
+
throw new ResponsesBridgeError(400, "Body is not valid JSON.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { request, customToolNames } = responsesToChatRequest(body);
|
|
127
|
+
const wantsStream = request.stream === true;
|
|
128
|
+
|
|
129
|
+
let upstream;
|
|
130
|
+
try {
|
|
131
|
+
upstream = await fetchImpl(chatUrl, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"content-type": "application/json",
|
|
135
|
+
accept: wantsStream ? "text/event-stream" : "application/json",
|
|
136
|
+
authorization: auth,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(request),
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
sendError(res, 502, `Gateway unreachable: ${err?.message ?? err}`, "api_error", "upstream_unreachable");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!upstream.ok) {
|
|
146
|
+
// Forward the gateway error message (billing/rate-limit/etc.) as clearly as possible.
|
|
147
|
+
const text = await upstream.text();
|
|
148
|
+
let message = text.slice(0, 500);
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(text);
|
|
151
|
+
message = parsed?.error?.message ?? (typeof parsed?.error === "string" ? parsed.error : null) ?? parsed?.message ?? message;
|
|
152
|
+
} catch {
|
|
153
|
+
/* body is not JSON */
|
|
154
|
+
}
|
|
155
|
+
sendError(res, upstream.status, `Upstream ${upstream.status}: ${message}`, "api_error", "upstream_error");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!wantsStream) {
|
|
160
|
+
let chat;
|
|
161
|
+
try {
|
|
162
|
+
chat = await upstream.json();
|
|
163
|
+
} catch {
|
|
164
|
+
sendError(res, 502, "Gateway response is not valid JSON.", "api_error", "invalid_upstream_response");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
sendJson(res, 200, chatJsonToResponse(chat, { requestedModel: request.model, customToolNames }));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!upstream.body) {
|
|
172
|
+
sendError(res, 502, "Gateway stream response has no body.", "api_error", "invalid_upstream_response");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
res.writeHead(200, {
|
|
177
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
178
|
+
"cache-control": "no-cache, no-transform",
|
|
179
|
+
connection: "keep-alive",
|
|
180
|
+
"x-accel-buffering": "no",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const stream = wrapResponsesStream(upstream.body, { requestedModel: request.model, customToolNames });
|
|
184
|
+
const reader = stream.getReader();
|
|
185
|
+
let closed = false;
|
|
186
|
+
req.on("close", () => {
|
|
187
|
+
closed = true;
|
|
188
|
+
void reader.cancel().catch(() => {});
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { done, value } = await reader.read();
|
|
193
|
+
if (done || closed) break;
|
|
194
|
+
if (!res.write(value)) {
|
|
195
|
+
await new Promise((resolve) => res.once("drain", resolve));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
res.end();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await new Promise((resolve, reject) => {
|
|
204
|
+
server.once("error", reject);
|
|
205
|
+
server.listen(port, host, resolve);
|
|
206
|
+
});
|
|
207
|
+
const address = server.address();
|
|
208
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
server,
|
|
212
|
+
host,
|
|
213
|
+
port: boundPort,
|
|
214
|
+
url: `http://${host}:${boundPort}/v1`,
|
|
215
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
216
|
+
};
|
|
217
|
+
}
|