jev-gateway 0.1.0 → 0.2.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/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} ${spec.client} with tool selection routed through Jev
38
+ const help = `${spec.name}: ${spec.client} with tool selection routed through Jev
39
39
 
40
- ${spec.name} [${spec.client} args] start the router if needed, then run ${spec.client} through it
41
- ${spec.name} --jev-start only start the background router
42
- ${spec.name} --jev-status is the router up, and where does it forward to?
43
- ${spec.name} --jev-logs follow the router's decisions (run in a second terminal)
44
- ${spec.name} --jev-stop stop the background router
45
- ${spec.name} --jev-config how to point plain \`${spec.client}\` at the router permanently
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} --jev-stop\` and try again.`);
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
- env: { ...env, PORT: String(port), UPSTREAM_BASE_URL: upstream },
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
- const stopRouter = () => {
104
- if (!existsSync(pidFile)) return console.log(`${spec.name}: no background router recorded.`);
105
- const pid = Number(readFileSync(pidFile, "utf8"));
106
- try {
107
- process.kill(pid);
108
- console.log(`${spec.name}: stopped router (pid ${pid}).`);
109
- } catch {
110
- console.log(`${spec.name}: router was not running.`);
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
- const [flag] = process.argv.slice(2);
116
- if (flag === "--jev-help") return console.log(help);
117
- if (flag === "--jev-stop") return stopRouter();
118
- if (flag === "--jev-config") return console.log(spec.configHelp(origin));
119
- if (flag === "--jev-start") {
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 === "--jev-status") {
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 === "--jev-logs") {
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; `--jev-stop` ends it.
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
- log({ ...entry, ...decision, status: response.status });
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
- log({ ...entry, ...decision, status: response.status });
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
- const presented = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
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)");