@alexeiled/claude-router 0.2.2 → 0.4.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/.claude-plugin/plugin.json +4 -4
- package/README.md +41 -15
- package/docs/configuration.md +28 -13
- package/docs/design.md +42 -2
- package/docs/user-guide.md +26 -12
- package/hooks/hooks.json +12 -0
- package/lib/config.mjs +10 -5
- package/lib/facts.mjs +7 -0
- package/lib/gateway.mjs +143 -58
- package/lib/idle.mjs +41 -0
- package/lib/jev.mjs +29 -8
- package/lib/policy.mjs +17 -0
- package/lib/router.mjs +82 -18
- package/lib/sse.mjs +7 -2
- package/lib/status.mjs +27 -7
- package/lib/store.mjs +40 -1
- package/package.json +2 -2
- package/scripts/ensure-gateway.mjs +78 -16
- package/scripts/gateway.mjs +64 -5
- package/skills/setup/SKILL.md +7 -6
- package/skills/status/SKILL.md +2 -1
package/lib/store.mjs
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
// Files: user config, per-session memory and the decision log. The only module that touches the filesystem.
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
3
12
|
import { join } from 'node:path';
|
|
4
13
|
|
|
14
|
+
export const LOG_LIMIT_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
const SESSION_TTL_MS = 30 * 24 * 3_600_000;
|
|
16
|
+
|
|
5
17
|
export function readJsonFile(path) {
|
|
6
18
|
try {
|
|
7
19
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -31,6 +43,33 @@ export function appendLog(dir, entry) {
|
|
|
31
43
|
appendFileSync(join(dir, 'decisions.jsonl'), `${JSON.stringify(entry)}\n`);
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
// Keeps one previous generation: `<file>.1`. Only for files written by path, not held open.
|
|
47
|
+
export function rotate(path, limitBytes = LOG_LIMIT_BYTES) {
|
|
48
|
+
try {
|
|
49
|
+
if (statSync(path).size > limitBytes) renameSync(path, `${path}.1`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code !== 'ENOENT') throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bounds the data directory of a long-running gateway: the decision log and a month of session memory.
|
|
56
|
+
// gateway.log is the daemon's open stderr, so the hook that starts the daemon rotates it instead.
|
|
57
|
+
export function housekeeping(dir, now = Date.now()) {
|
|
58
|
+
rotate(join(dir, 'decisions.jsonl'));
|
|
59
|
+
const sessions = join(dir, 'sessions');
|
|
60
|
+
let names;
|
|
61
|
+
try {
|
|
62
|
+
names = readdirSync(sessions);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const path = join(sessions, name);
|
|
69
|
+
if (now - statSync(path).mtimeMs > SESSION_TTL_MS) rmSync(path, { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
function memoryPath(dir, sessionId) {
|
|
35
74
|
return join(dir, 'sessions', `${String(sessionId).replace(/[^\w.-]/g, '_')}.json`);
|
|
36
75
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexeiled/claude-router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude Code plugin:
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Claude Code plugin: auto-picks the right model and effort for each turn using Jev routing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexei Ledenev",
|
|
7
7
|
"repository": {
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SessionStart
|
|
2
|
+
// Hook entry (SessionStart, UserPromptSubmit): make sure a gateway of this plugin version or newer answers on the port.
|
|
3
|
+
// Starts one on a free port and replaces an older one; leaves a newer one alone, so a session that still runs an older
|
|
4
|
+
// plugin never downgrades it. Detached, so the gateway outlives the session. After a crash, the next prompt restarts it.
|
|
5
|
+
// `--quiet` prints nothing: UserPromptSubmit output goes into the model's context.
|
|
3
6
|
import { spawn } from 'node:child_process';
|
|
4
7
|
import { mkdirSync, openSync } from 'node:fs';
|
|
5
8
|
import { connect } from 'node:net';
|
|
6
9
|
import { dirname, join } from 'node:path';
|
|
7
10
|
import { fileURLToPath } from 'node:url';
|
|
8
11
|
import { loadRuntime } from '../lib/runtime.mjs';
|
|
12
|
+
import { isOlderVersion, ROUTER_VERSION, STATUS_PATH } from '../lib/status.mjs';
|
|
13
|
+
import { rotate } from '../lib/store.mjs';
|
|
14
|
+
|
|
15
|
+
const WAIT_STEP_MS = 100;
|
|
16
|
+
const WAIT_STEPS = 20;
|
|
17
|
+
const STATUS_TIMEOUT_MS = 500;
|
|
9
18
|
|
|
10
19
|
const env = process.env;
|
|
20
|
+
const quiet = process.argv.includes('--quiet');
|
|
11
21
|
const { config, dataDir } = loadRuntime(env);
|
|
12
22
|
const port = config.gateway.port;
|
|
13
23
|
|
|
24
|
+
function say(message) {
|
|
25
|
+
if (!quiet) process.stdout.write(`router: ${message}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
function probe() {
|
|
15
29
|
return new Promise((resolve) => {
|
|
16
30
|
const socket = connect({ host: '127.0.0.1', port });
|
|
@@ -22,15 +36,56 @@ function probe() {
|
|
|
22
36
|
});
|
|
23
37
|
}
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
// Waits until the port answers (`true`) or is free (`false`).
|
|
40
|
+
async function waitFor(answering) {
|
|
41
|
+
for (let i = 0; i < WAIT_STEPS; i += 1) {
|
|
42
|
+
if ((await probe()) === answering) return true;
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_STEP_MS));
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runningGateway() {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`http://127.0.0.1:${port}${STATUS_PATH}`, {
|
|
51
|
+
signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
return res.ok ? await res.json() : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SIGTERM: the old gateway releases the port at once and finishes its open streams. Gateways from 0.4.0 on report
|
|
60
|
+
// their pid; older ones cannot be retired from here.
|
|
61
|
+
async function retire(running) {
|
|
62
|
+
const from = running.version ?? 'before 0.3.0';
|
|
63
|
+
if (!running.pid) {
|
|
64
|
+
say(
|
|
65
|
+
`gateway ${from} runs on port ${port}. Stop it once with \`pkill -f scripts/gateway.mjs\`; the next prompt starts ${ROUTER_VERSION}.`,
|
|
66
|
+
);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
process.kill(running.pid, 'SIGTERM');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
say(`cannot stop gateway ${from} (pid ${running.pid}): ${error.message}`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (await waitFor(false)) return true;
|
|
76
|
+
say(`gateway ${from} (pid ${running.pid}) did not release port ${port}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
30
79
|
|
|
31
|
-
|
|
80
|
+
async function start() {
|
|
32
81
|
mkdirSync(dataDir, { recursive: true });
|
|
33
|
-
const
|
|
82
|
+
const logPath = join(dataDir, 'gateway.log');
|
|
83
|
+
try {
|
|
84
|
+
rotate(logPath);
|
|
85
|
+
} catch {
|
|
86
|
+
// Rotation is housekeeping; a gateway that starts matters more.
|
|
87
|
+
}
|
|
88
|
+
const log = openSync(logPath, 'a');
|
|
34
89
|
// Claude Code exports the plugin option as CLAUDE_PLUGIN_OPTION_<KEY>; the gateway reads one name only.
|
|
35
90
|
const childEnv = {
|
|
36
91
|
...env,
|
|
@@ -43,13 +98,20 @@ if (!(await probe())) {
|
|
|
43
98
|
});
|
|
44
99
|
child.unref();
|
|
45
100
|
// Wait for the listener so the session's first request finds it.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
101
|
+
return { pid: child.pid, up: await waitFor(true) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!(env.ANTHROPIC_BASE_URL ?? '').includes(`127.0.0.1:${port}`))
|
|
105
|
+
say('Claude Code does not use the gateway yet. Run /router:setup, then restart Claude Code.');
|
|
106
|
+
|
|
107
|
+
const running = await runningGateway();
|
|
108
|
+
if (running && !isOlderVersion(running.version, ROUTER_VERSION)) process.exit(0);
|
|
109
|
+
if (running && !(await retire(running))) process.exit(0);
|
|
110
|
+
if (!running && (await probe())) {
|
|
111
|
+
say(`port ${port} answers, but not as the router gateway`);
|
|
112
|
+
process.exit(0);
|
|
54
113
|
}
|
|
114
|
+
const { pid, up } = await start();
|
|
115
|
+
const verb = running ? `replaced ${running.version ?? 'an older gateway'} on` : 'started on';
|
|
116
|
+
say(`gateway ${ROUTER_VERSION} ${up ? verb : 'not answering yet on'} 127.0.0.1:${port} (pid ${pid})`);
|
|
55
117
|
process.exit(0);
|
package/scripts/gateway.mjs
CHANGED
|
@@ -1,12 +1,71 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Daemon entry: local gateway on 127.0.0.1:<gateway.port>. Started by the
|
|
2
|
+
// Daemon entry: local gateway on 127.0.0.1:<gateway.port>. Started by the plugin hooks or by hand.
|
|
3
3
|
import { createGateway } from '../lib/gateway.mjs';
|
|
4
|
+
import { IdleTracker } from '../lib/idle.mjs';
|
|
4
5
|
import { Router } from '../lib/router.mjs';
|
|
5
6
|
import { loadRuntime } from '../lib/runtime.mjs';
|
|
7
|
+
import { ROUTER_VERSION } from '../lib/status.mjs';
|
|
8
|
+
import { housekeeping } from '../lib/store.mjs';
|
|
9
|
+
|
|
10
|
+
// On stop the port is released at once; open streams get this long to finish (Claude Code's request timeout).
|
|
11
|
+
const DRAIN_MS = 600_000;
|
|
12
|
+
const HOUSEKEEPING_MS = 3_600_000;
|
|
13
|
+
const IDLE_CHECK_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
process.stderr.on('error', () => {}); // a full disk must not turn a log line into a crash
|
|
16
|
+
const log = (message) => process.stderr.write(`${new Date().toISOString()} router: ${message}\n`);
|
|
17
|
+
const onError = (error) => log(error.message);
|
|
18
|
+
|
|
19
|
+
// Every Claude Code session on the machine goes through this process, so a stray exception is logged, not fatal.
|
|
20
|
+
// Request state is per request and per session; the worst case after such an error is one misrouted turn.
|
|
21
|
+
process.on('uncaughtException', (error) => log(`uncaught exception: ${error.stack ?? error.message}`));
|
|
22
|
+
process.on('unhandledRejection', (reason) => log(`unhandled rejection: ${reason?.stack ?? reason}`));
|
|
6
23
|
|
|
7
24
|
const { config, dataDir } = loadRuntime(process.env);
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
25
|
+
const { port, alias, idleShutdownMs } = config.gateway;
|
|
26
|
+
const router = new Router({ config, fetchFn: globalThis.fetch, dataDir, onError });
|
|
27
|
+
const activity = new IdleTracker();
|
|
28
|
+
const server = createGateway({ router, onError, activity });
|
|
29
|
+
|
|
30
|
+
server.on('error', (error) => {
|
|
31
|
+
if (error.code === 'EADDRINUSE') {
|
|
32
|
+
log(`port ${port} is in use, another gateway serves it: exiting`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
log(`server error: ${error.message}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let stopping = false;
|
|
40
|
+
function stop(reason) {
|
|
41
|
+
if (stopping) return;
|
|
42
|
+
stopping = true;
|
|
43
|
+
log(`${reason}: no new connections, finishing open streams`);
|
|
44
|
+
server.close(() => process.exit(0));
|
|
45
|
+
server.closeIdleConnections();
|
|
46
|
+
setTimeout(() => process.exit(0), DRAIN_MS).unref();
|
|
47
|
+
}
|
|
48
|
+
process.once('SIGTERM', () => stop('SIGTERM'));
|
|
49
|
+
process.once('SIGINT', () => stop('SIGINT'));
|
|
50
|
+
|
|
51
|
+
if (idleShutdownMs > 0) {
|
|
52
|
+
const minutes = Math.round(idleShutdownMs / 60_000);
|
|
53
|
+
const check = () => {
|
|
54
|
+
if (activity.idle(idleShutdownMs)) stop(`idle for ${minutes} min, no turn waits for a tool`);
|
|
55
|
+
};
|
|
56
|
+
setInterval(check, Math.min(idleShutdownMs, IDLE_CHECK_MS)).unref();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tidy() {
|
|
60
|
+
try {
|
|
61
|
+
housekeeping(dataDir);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
onError(error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
tidy();
|
|
67
|
+
setInterval(tidy, HOUSEKEEPING_MS).unref();
|
|
68
|
+
|
|
69
|
+
server.listen(port, '127.0.0.1', () =>
|
|
70
|
+
log(`${ROUTER_VERSION} listening on http://127.0.0.1:${port} as ${alias} (pid ${process.pid})`),
|
|
12
71
|
);
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup
|
|
3
|
-
description: Point Claude Code at the router gateway. Adds `model`, `ANTHROPIC_BASE_URL
|
|
3
|
+
description: Point Claude Code at the router gateway. Adds `model`, `ANTHROPIC_BASE_URL`, the context window and the `/model` picker row to the user settings, and offers the status line.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
allowed-tools: Read, Edit, Write
|
|
6
6
|
---
|
|
@@ -13,12 +13,13 @@ Configure Claude Code for the router gateway. Do these steps:
|
|
|
13
13
|
4. Set these keys in `env`, for the `/model` picker row:
|
|
14
14
|
- `ANTHROPIC_CUSTOM_MODEL_OPTION`: `"router"`
|
|
15
15
|
- `ANTHROPIC_CUSTOM_MODEL_OPTION_NAME`: `"Router (auto)"`
|
|
16
|
-
- `ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION`: `"
|
|
17
|
-
5.
|
|
18
|
-
6.
|
|
19
|
-
7.
|
|
16
|
+
- `ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION`: `"Auto-selects the model and effort for each turn"`
|
|
17
|
+
5. Set the key `env.CLAUDE_CODE_MAX_CONTEXT_TOKENS` to `"1000000"`. Claude Code does not know the model `router` and assumes a 200K window without this key. If `~/.claude/router.json` changes `routes` or `models`, use the largest `contextWindow` of the models that the routes use. The gateway sends a turn only to a model whose window holds the context.
|
|
18
|
+
6. Keep every other key unchanged. Write the file.
|
|
19
|
+
7. The status line can show the model and effort of the last routed turn. The command is `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs`, followed by the current status line command if there is one (for example `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs claude-powerline`). Show the user the current `statusLine` value and the new one, and ask. Change `statusLine.command` only if the user agrees. Keep the other `statusLine` keys.
|
|
20
|
+
8. Tell the user:
|
|
20
21
|
- Restart Claude Code. Then the router serves each turn, and `/router:status` shows the routes and the last turn.
|
|
21
22
|
- The status line path contains the plugin version. After a plugin update, run `/router:setup` again.
|
|
22
|
-
- To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL` and the three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys, and restore the status line command.
|
|
23
|
+
- To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL`, `env.CLAUDE_CODE_MAX_CONTEXT_TOKENS` and the three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys, and restore the status line command.
|
|
23
24
|
|
|
24
25
|
Do not change any other file.
|
package/skills/status/SKILL.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: status
|
|
3
|
-
description: Show
|
|
3
|
+
description: Show which model served the last turn, whether Jev routing is active, and the routing config.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
allowed-tools: Bash(node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs *)
|
|
6
6
|
---
|
|
7
|
+
|
|
7
8
|
Router status:
|
|
8
9
|
|
|
9
10
|
!`node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs ${CLAUDE_SESSION_ID}`
|