@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 CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # @rerouter/helper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }