jev-gateway 0.1.0 → 0.2.0
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/.env.example +12 -0
- package/CHANGELOG.md +36 -0
- package/README.md +220 -119
- package/bin/jev-claude.mjs +1 -1
- package/bin/jev-codex.mjs +1 -1
- package/bin/launcher.mjs +96 -26
- package/dist/app.js +33 -6
- package/dist/config.js +5 -1
- package/dist/dashboard.html +500 -0
- package/dist/dashboard.js +57 -0
- package/dist/events.js +101 -0
- package/dist/index.js +4 -1
- package/dist/usage.js +86 -0
- package/package.json +14 -5
package/bin/launcher.mjs
CHANGED
|
@@ -35,19 +35,23 @@ export async function runLauncher(spec) {
|
|
|
35
35
|
const logFile = join(STATE_DIR, `${spec.client}.log`);
|
|
36
36
|
const pidFile = join(STATE_DIR, `${spec.client}.pid`);
|
|
37
37
|
|
|
38
|
-
const help = `${spec.name}
|
|
38
|
+
const help = `${spec.name}: ${spec.client} with tool selection routed through Jev
|
|
39
39
|
|
|
40
|
-
${spec.name} [${spec.client} args
|
|
41
|
-
${spec.name} --
|
|
42
|
-
${spec.name} --
|
|
43
|
-
${spec.name} --
|
|
44
|
-
${spec.name} --
|
|
45
|
-
${spec.name} --
|
|
40
|
+
${spec.name} [${spec.client} args] start the gateway if needed, then run ${spec.client} through it
|
|
41
|
+
${spec.name} --dashboard open the monitoring dashboard in your browser
|
|
42
|
+
${spec.name} --routing on|off off = baseline mode: stop asking Jev, keep counting tokens
|
|
43
|
+
${spec.name} --status is the gateway running, and where does it forward to?
|
|
44
|
+
${spec.name} --logs follow routing decisions live (use a second terminal)
|
|
45
|
+
${spec.name} --start start the gateway without opening ${spec.client}
|
|
46
|
+
${spec.name} --stop stop the background gateway
|
|
47
|
+
${spec.name} --print-config how to point plain \`${spec.client}\` at the gateway permanently
|
|
48
|
+
${spec.name} --gateway-help this text (\`--help\` shows ${spec.client}'s own help)
|
|
46
49
|
|
|
47
50
|
Environment (or ${ENV_FILES.at(-1)}):
|
|
48
51
|
TYPESAFE_API_KEY required — Jev makes the tool-selection call
|
|
49
52
|
${spec.portEnv} router port for ${spec.client} (default ${spec.defaultPort})
|
|
50
53
|
${spec.upstreamHelp}
|
|
54
|
+
BROWSER command --dashboard opens the page with; "none" only prints the URL
|
|
51
55
|
`;
|
|
52
56
|
|
|
53
57
|
const health = async () => {
|
|
@@ -68,7 +72,7 @@ Environment (or ${ENV_FILES.at(-1)}):
|
|
|
68
72
|
if (running) {
|
|
69
73
|
if (running.upstream === upstream) return;
|
|
70
74
|
console.error(`${spec.name}: router on :${port} forwards to ${running.upstream}, expected ${upstream}.`);
|
|
71
|
-
console.error(`${" ".repeat(spec.name.length)} Run \`${spec.name} --
|
|
75
|
+
console.error(`${" ".repeat(spec.name.length)} Run \`${spec.name} --stop\` and try again.`);
|
|
72
76
|
process.exit(1);
|
|
73
77
|
}
|
|
74
78
|
if (!process.env.TYPESAFE_API_KEY) {
|
|
@@ -82,7 +86,8 @@ Environment (or ${ENV_FILES.at(-1)}):
|
|
|
82
86
|
const { UPSTREAM_API_KEY: _key, ROUTER_API_KEY: _routerKey, ...env } = process.env;
|
|
83
87
|
const child = spawn(process.execPath, ROUTER_ARGS, {
|
|
84
88
|
cwd: ROOT,
|
|
85
|
-
|
|
89
|
+
// JEV_LOG_FILE is where stdout goes (below): the router replays it so the dashboard keeps its history.
|
|
90
|
+
env: { ...env, PORT: String(port), UPSTREAM_BASE_URL: upstream, JEV_CLIENT: spec.client, JEV_LOG_FILE: logFile },
|
|
86
91
|
detached: true,
|
|
87
92
|
stdio: ["ignore", log, log],
|
|
88
93
|
});
|
|
@@ -100,32 +105,97 @@ Environment (or ${ENV_FILES.at(-1)}):
|
|
|
100
105
|
process.exit(1);
|
|
101
106
|
};
|
|
102
107
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// Pid files written before the project was renamed; a router started back then is still running.
|
|
109
|
+
const legacyPidFile = join(homedir(), ".jev-router", `${spec.client}.pid`);
|
|
110
|
+
|
|
111
|
+
const stopRouter = async () => {
|
|
112
|
+
// Whoever answers on the port is the router to stop; pid files only cover ones that don't say.
|
|
113
|
+
const candidates = [(await health())?.pid];
|
|
114
|
+
for (const file of [pidFile, legacyPidFile]) {
|
|
115
|
+
if (existsSync(file)) candidates.push(Number(readFileSync(file, "utf8")));
|
|
116
|
+
rmSync(file, { force: true });
|
|
117
|
+
}
|
|
118
|
+
const pids = [...new Set(candidates.filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
119
|
+
let stopped = false;
|
|
120
|
+
for (const pid of pids) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid);
|
|
123
|
+
stopped = true;
|
|
124
|
+
console.log(`${spec.name}: stopped router (pid ${pid}).`);
|
|
125
|
+
} catch {
|
|
126
|
+
// Already gone.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!stopped) console.log(`${spec.name}: no router was running.`);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Whichever opener this platform has; under WSL the browser lives on the Windows side. */
|
|
133
|
+
const openBrowser = async (url) => {
|
|
134
|
+
const wsl = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
|
|
135
|
+
const openers = [
|
|
136
|
+
...(process.env.BROWSER ? [[process.env.BROWSER, url]] : []),
|
|
137
|
+
...(process.platform === "darwin" ? [["open", url]] : []),
|
|
138
|
+
...(process.platform === "win32" ? [["cmd", "/c", "start", "", url]] : []),
|
|
139
|
+
...(wsl ? [["wslview", url], ["cmd.exe", "/c", "start", "", url]] : []),
|
|
140
|
+
["xdg-open", url],
|
|
141
|
+
];
|
|
142
|
+
for (const [command, ...args] of openers) {
|
|
143
|
+
const started = await new Promise((done) => {
|
|
144
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
145
|
+
child.once("error", () => done(false));
|
|
146
|
+
child.once("spawn", () => (child.unref(), done(true)));
|
|
147
|
+
});
|
|
148
|
+
if (started) return;
|
|
111
149
|
}
|
|
112
|
-
rmSync(pidFile, { force: true });
|
|
113
150
|
};
|
|
114
151
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
152
|
+
// Only names neither client uses: `--help` and `--config` stay theirs, so those two are spelled
|
|
153
|
+
// differently here. The original `--jev-*` spellings still work.
|
|
154
|
+
const LEGACY = { "--jev-config": "--print-config", "--jev-help": "--gateway-help" };
|
|
155
|
+
const [first] = process.argv.slice(2);
|
|
156
|
+
const flag = LEGACY[first] ?? first?.replace(/^--jev-(?=dashboard$|routing$|status$|logs$|start$|stop$)/, "--");
|
|
157
|
+
if (flag === "--gateway-help") return console.log(help);
|
|
158
|
+
if (flag === "--stop") return await stopRouter();
|
|
159
|
+
if (flag === "--routing") {
|
|
160
|
+
const wanted = process.argv[3];
|
|
161
|
+
if (wanted !== "on" && wanted !== "off") return console.error(`usage: ${spec.name} --routing on|off`);
|
|
162
|
+
await ensureRouter();
|
|
163
|
+
const key = process.env.ROUTER_API_KEY ? `&key=${encodeURIComponent(process.env.ROUTER_API_KEY)}` : "";
|
|
164
|
+
const response = await fetch(`${origin}/dashboard/routing?enabled=${wanted === "on"}${key}`, { method: "POST" });
|
|
165
|
+
if (!response.ok) return console.error(`${spec.name}: the router refused (${response.status}). Run \`${spec.name} --stop\` and try again.`);
|
|
166
|
+
return console.log(
|
|
167
|
+
wanted === "on"
|
|
168
|
+
? `${spec.name}: routing on. Jev decides again.`
|
|
169
|
+
: `${spec.name}: routing off (baseline mode). Requests go straight to the LLM, tokens are still metered.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (flag === "--print-config") return console.log(spec.configHelp(origin));
|
|
173
|
+
if (flag === "--start") {
|
|
120
174
|
await ensureRouter();
|
|
121
175
|
return console.log(`${spec.name}: router up on ${origin} → ${spec.upstream()} (logs: ${logFile})`);
|
|
122
176
|
}
|
|
123
|
-
if (flag === "--
|
|
177
|
+
if (flag === "--status") {
|
|
124
178
|
const running = await health();
|
|
125
179
|
console.log(running ? `${spec.name}: router up on ${origin} → ${running.upstream}` : `${spec.name}: router is not running`);
|
|
126
180
|
return console.log(`logs: ${logFile}`);
|
|
127
181
|
}
|
|
128
|
-
if (flag === "--
|
|
182
|
+
if (flag === "--dashboard") {
|
|
183
|
+
await ensureRouter();
|
|
184
|
+
// `localhost`, not 127.0.0.1: it is the name WSL forwards to a browser running on Windows.
|
|
185
|
+
const base = `http://localhost:${port}/dashboard`;
|
|
186
|
+
// The page looks for the other launchers' routers on their default ports; tell it about moved ones.
|
|
187
|
+
const peers = Object.entries(process.env).flatMap(([name, value]) => (/^JEV_[A-Z]+_PORT$/.test(name) && value !== String(port) ? [value] : []));
|
|
188
|
+
const url = peers.length ? `${base}?peers=${peers.join(",")}` : base;
|
|
189
|
+
const served = await fetch(url).then((response) => response.ok, () => false);
|
|
190
|
+
if (!served) {
|
|
191
|
+
console.error(`${spec.name}: the router on :${port} predates the dashboard. Run \`${spec.name} --stop\` and try again.`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
console.log(`${spec.name}: dashboard at ${url}`);
|
|
195
|
+
if (process.env.BROWSER !== "none") await openBrowser(url);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (flag === "--logs") {
|
|
129
199
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
130
200
|
closeSync(openSync(logFile, "a"));
|
|
131
201
|
return spawn("tail", ["-n", "30", "-f", logFile], { stdio: "inherit" });
|
|
@@ -140,7 +210,7 @@ Environment (or ${ENV_FILES.at(-1)}):
|
|
|
140
210
|
console.error(`${spec.name}: could not run ${spec.client}: ${error.message}`);
|
|
141
211
|
process.exit(127);
|
|
142
212
|
});
|
|
143
|
-
// The router is left running for the next session; `--
|
|
213
|
+
// The router is left running for the next session; `--stop` ends it.
|
|
144
214
|
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
145
215
|
// Ctrl-C reaches the client directly (same foreground process group); it decides what that means.
|
|
146
216
|
process.on("SIGINT", () => {});
|
package/dist/app.js
CHANGED
|
@@ -4,9 +4,12 @@ import { Hono } from "hono";
|
|
|
4
4
|
import { chatAdapter } from "./adapters/chat.js";
|
|
5
5
|
import { messagesAdapter } from "./adapters/messages.js";
|
|
6
6
|
import { responsesAdapter } from "./adapters/responses.js";
|
|
7
|
+
import { dashboardRoutes } from "./dashboard.js";
|
|
7
8
|
import { redactHeaders, summarizeResponse } from "./debug.js";
|
|
8
9
|
import { decide } from "./decide.js";
|
|
10
|
+
import { createEventLog } from "./events.js";
|
|
9
11
|
import { forward } from "./upstream.js";
|
|
12
|
+
import { readUsage } from "./usage.js";
|
|
10
13
|
const safeEqual = (a, b) => {
|
|
11
14
|
const left = Buffer.from(a);
|
|
12
15
|
const right = Buffer.from(b);
|
|
@@ -44,8 +47,24 @@ function decisionHeaders(decision) {
|
|
|
44
47
|
}
|
|
45
48
|
return headers;
|
|
46
49
|
}
|
|
47
|
-
export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () => { }, dump }) {
|
|
50
|
+
export function createApp({ config, askJev, fetch: fetchImpl = fetch, log: writeLog = () => { }, events = createEventLog(), dump }) {
|
|
48
51
|
const app = new Hono();
|
|
52
|
+
/** One routed request: a line in the log, and a row on the dashboard. */
|
|
53
|
+
const log = (entry) => {
|
|
54
|
+
writeLog(entry);
|
|
55
|
+
events.record(entry);
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Log a forwarded request once its reply has ended, because that is when the provider says what
|
|
59
|
+
* it cost. The reply is read from a clone in the background, so the client is never delayed.
|
|
60
|
+
*/
|
|
61
|
+
const logWhenDone = (entry, response, startedAt) => {
|
|
62
|
+
const copy = response.clone();
|
|
63
|
+
void readUsage(copy).then((usage) => log({ ...entry, status: response.status, durationMs: Math.round(performance.now() - startedAt), ...(usage ? { usage } : {}) }));
|
|
64
|
+
};
|
|
65
|
+
// Routing can be switched off at runtime to measure a baseline: same clients, same traffic,
|
|
66
|
+
// same token accounting, but Jev is never asked and nothing is rewritten.
|
|
67
|
+
let routing = config.routing;
|
|
49
68
|
/**
|
|
50
69
|
* Error bodies are the only documentation an undocumented backend offers, and a finished
|
|
51
70
|
* stream's usage is the only way to see what a rewrite did to the prompt cache: keep both.
|
|
@@ -80,6 +99,8 @@ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () =
|
|
|
80
99
|
return { decision: await decide(input, config, askJev), tools: input.tools.length };
|
|
81
100
|
};
|
|
82
101
|
const route = (adapter) => async (c) => {
|
|
102
|
+
const startedAt = performance.now();
|
|
103
|
+
const time = new Date().toISOString();
|
|
83
104
|
const bytes = new Uint8Array(await c.req.arrayBuffer());
|
|
84
105
|
// Unreadable bodies are not ours to judge: upstream produces its own error for them.
|
|
85
106
|
const req = parseBody(bytes, c.req.header("content-encoding"));
|
|
@@ -95,9 +116,11 @@ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () =
|
|
|
95
116
|
decision = { mode: "passthrough", reason: "unparseable_body" };
|
|
96
117
|
else if (c.req.header("x-jev-gateway") === "off")
|
|
97
118
|
decision = { mode: "passthrough", reason: "disabled_by_header" };
|
|
119
|
+
else if (!routing)
|
|
120
|
+
decision = { mode: "passthrough", reason: "routing_disabled" };
|
|
98
121
|
else
|
|
99
122
|
({ decision, tools } = await decideFor(adapter, req));
|
|
100
|
-
const entry = { event: "route", path: c.req.path, model: req?.model, tools: tools ?? req?.tools?.length ?? 0 };
|
|
123
|
+
const entry = { event: "route", time, path: c.req.path, model: req?.model, tools: tools ?? req?.tools?.length ?? 0 };
|
|
101
124
|
if (req && decision.mode === "direct") {
|
|
102
125
|
log({ ...entry, ...decision });
|
|
103
126
|
const call = { tool: decision.tool, args: decision.args, inputTokens: decision.jev?.inputTokens ?? 0 };
|
|
@@ -117,7 +140,7 @@ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () =
|
|
|
117
140
|
const sent = { mode: decision.mode, model: rewritten.model, tool_choice: rewritten.tool_choice };
|
|
118
141
|
dumpResponse("rejected", response, { sent });
|
|
119
142
|
if (response.status !== 400 && response.status !== 422) {
|
|
120
|
-
|
|
143
|
+
logWhenDone({ ...entry, ...decision }, response, startedAt);
|
|
121
144
|
return response;
|
|
122
145
|
}
|
|
123
146
|
// The upstream refused the rewritten request (some backends only accept tool_choice
|
|
@@ -129,15 +152,18 @@ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () =
|
|
|
129
152
|
body: bytes,
|
|
130
153
|
responseHeaders: decisionHeaders(decision),
|
|
131
154
|
});
|
|
132
|
-
|
|
155
|
+
logWhenDone({ ...entry, ...decision }, response, startedAt);
|
|
133
156
|
dumpResponse("upstream-error", response);
|
|
134
157
|
return response;
|
|
135
158
|
};
|
|
136
|
-
app.get("/health", (c) => c.json({ status: "ok", upstream: config.upstreamBaseUrl }));
|
|
159
|
+
app.get("/health", (c) => c.json({ status: "ok", pid: process.pid, upstream: config.upstreamBaseUrl }));
|
|
137
160
|
app.use("*", async (c, next) => {
|
|
138
161
|
if (!config.routerApiKey)
|
|
139
162
|
return next();
|
|
140
|
-
|
|
163
|
+
// A browser can't attach a header to a page it navigates to, so the dashboard — and only the
|
|
164
|
+
// dashboard — may carry the key as `?key=`.
|
|
165
|
+
const inQuery = c.req.path.startsWith("/dashboard") ? c.req.query("key") : undefined;
|
|
166
|
+
const presented = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? inQuery ?? "";
|
|
141
167
|
if (safeEqual(presented, config.routerApiKey))
|
|
142
168
|
return next();
|
|
143
169
|
return c.json({ error: { message: "Invalid jev-gateway API key", type: "invalid_api_key" } }, 401);
|
|
@@ -163,6 +189,7 @@ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () =
|
|
|
163
189
|
const adapter = (adapters[format] ?? adapters[guess]);
|
|
164
190
|
return c.json((await decideFor(adapter, req)).decision);
|
|
165
191
|
});
|
|
192
|
+
app.route("/dashboard", dashboardRoutes(config, events, { get: () => routing, set: (enabled) => void (routing = enabled) }));
|
|
166
193
|
app.post("/v1/chat/completions", route(chatAdapter));
|
|
167
194
|
app.post("/v1/responses", route(responsesAdapter));
|
|
168
195
|
app.post("/v1/messages", route(messagesAdapter));
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,7 @@ const bool = (env, key, fallback) => {
|
|
|
15
15
|
const raw = str(env, key)?.toLowerCase();
|
|
16
16
|
if (raw === undefined)
|
|
17
17
|
return fallback;
|
|
18
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
18
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
19
19
|
};
|
|
20
20
|
export function loadConfig(env = process.env) {
|
|
21
21
|
const onNone = str(env, "JEV_ON_NONE") ?? "force_none";
|
|
@@ -23,6 +23,7 @@ export function loadConfig(env = process.env) {
|
|
|
23
23
|
throw new Error(`JEV_ON_NONE must be "force_none" or "passthrough", got "${onNone}"`);
|
|
24
24
|
}
|
|
25
25
|
const config = {
|
|
26
|
+
host: str(env, "HOST") ?? "127.0.0.1",
|
|
26
27
|
port: num(env, "PORT", 8787),
|
|
27
28
|
upstreamBaseUrl: (str(env, "UPSTREAM_BASE_URL") ?? "https://api.openai.com/v1").replace(/\/+$/, ""),
|
|
28
29
|
upstreamApiKey: str(env, "UPSTREAM_API_KEY"),
|
|
@@ -34,9 +35,12 @@ export function loadConfig(env = process.env) {
|
|
|
34
35
|
argMinCertainty: num(env, "JEV_ARG_MIN_CERTAINTY", 0.8),
|
|
35
36
|
onNone,
|
|
36
37
|
directCalls: bool(env, "JEV_DIRECT_CALLS", true),
|
|
38
|
+
routing: bool(env, "JEV_ROUTING", true),
|
|
37
39
|
maxStateChars: num(env, "JEV_MAX_STATE_CHARS", 60_000),
|
|
38
40
|
maxMessageChars: num(env, "JEV_MAX_MESSAGE_CHARS", 4_000),
|
|
39
41
|
debugDumpDir: str(env, "JEV_DEBUG_DUMP_DIR"),
|
|
42
|
+
client: str(env, "JEV_CLIENT") ?? "standalone",
|
|
43
|
+
logFile: str(env, "JEV_LOG_FILE"),
|
|
40
44
|
};
|
|
41
45
|
if (config.routerApiKey && !config.upstreamApiKey) {
|
|
42
46
|
throw new Error("ROUTER_API_KEY requires UPSTREAM_API_KEY (the client key is not valid upstream)");
|