solrouter 0.4.2 → 0.4.4
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/proxy.js +19 -0
- package/package.json +1 -1
- package/src/route.js +26 -2
- package/src/server.js +23 -2
package/bin/proxy.js
CHANGED
|
@@ -18,6 +18,23 @@ const [, , cmdRaw, ...rest] = process.argv;
|
|
|
18
18
|
const cmd = cmdRaw || "run";
|
|
19
19
|
const flag = name => { const i = rest.indexOf(name); return i >= 0 ? (rest[i + 1] ?? true) : undefined; };
|
|
20
20
|
|
|
21
|
+
// Find a genuinely free port starting from `start`. Prevents the footgun where a
|
|
22
|
+
// STALE proxy already on the port gets silently reused, so old code keeps running
|
|
23
|
+
// after a version bump.
|
|
24
|
+
async function freePort(start) {
|
|
25
|
+
const net = await import("node:net");
|
|
26
|
+
for (let p = start; p < start + 25; p++) {
|
|
27
|
+
const ok = await new Promise(res => {
|
|
28
|
+
const s = net.createServer();
|
|
29
|
+
s.once("error", () => res(false));
|
|
30
|
+
s.once("listening", () => s.close(() => res(true)));
|
|
31
|
+
s.listen(p, "127.0.0.1");
|
|
32
|
+
});
|
|
33
|
+
if (ok) return p;
|
|
34
|
+
}
|
|
35
|
+
return start;
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
// Start the proxy in-process, then launch the chosen tool through it (Claude Code),
|
|
22
39
|
// or — for GUI tools — keep the proxy up and print how to point the tool at it.
|
|
23
40
|
async function startProxyAndLaunch(cfg, tool, launch, passThru = []) {
|
|
@@ -112,6 +129,8 @@ async function main() {
|
|
|
112
129
|
const { banner } = await import("../src/banner.js");
|
|
113
130
|
console.log(banner({ menu: false }));
|
|
114
131
|
const { spawn } = await import("node:child_process");
|
|
132
|
+
// ALWAYS start our own proxy on a free port — never reuse a stale one.
|
|
133
|
+
cfg.port = await freePort(cfg.port);
|
|
115
134
|
startServer(cfg); // non-blocking listener in this process
|
|
116
135
|
// Pass everything after `code`/`claude` (minus our own --port/--mode) to Claude Code.
|
|
117
136
|
const passThru = rest.filter((a, i) =>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solrouter",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Local privacy router for coding tools: pseudonymises every request before it leaves your machine, and sends work a frontier model would refuse to an uncensored open-weight model you control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/route.js
CHANGED
|
@@ -42,11 +42,35 @@ export function likelyRefused(text) {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Softer decline signals — phrases refusals use even when they don't start with
|
|
46
|
+
// "I can't". Used only for SHORT, complete responses (a long substantive answer
|
|
47
|
+
// that happens to contain one of these is not a refusal).
|
|
48
|
+
const DECLINE_HINTS = [
|
|
49
|
+
/\bI (?:can(?:'|no)?t|won'?t|cannot|will not)\b/i,
|
|
50
|
+
/\bI'?m not (?:going to|able to|willing to|comfortable|going)\b/i,
|
|
51
|
+
/\bI (?:won'?t|don'?t|do not) (?:feel comfortable|think I should|be able)\b/i,
|
|
52
|
+
/\bI'?d (?:rather not|prefer not)\b/i,
|
|
53
|
+
/\b(?:not something I (?:can|will)|that'?s not something)\b/i,
|
|
54
|
+
/\bI'?m (?:sorry|afraid)\b/i,
|
|
55
|
+
/\b(?:against|violates?)\b.{0,30}\b(?:policy|policies|guidelines)\b/i,
|
|
56
|
+
/\bhappy to (?:help|pick|assist)\b.{0,40}\binstead\b/i,
|
|
57
|
+
/\b(?:instead|rather)\b.{0,30}\b(?:I'?m here|happy to|let me)\b/i,
|
|
58
|
+
/\bI (?:must|have to|need to) (?:decline|refuse|pass)\b/i,
|
|
59
|
+
/\bnot appropriate\b/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
45
62
|
/** Did a model response come back as a refusal? Used for the reactive retry. */
|
|
46
63
|
export function isRefusal(text) {
|
|
47
64
|
const s = String(text ?? "").trim();
|
|
48
|
-
if (!s
|
|
49
|
-
|
|
65
|
+
if (!s) return false;
|
|
66
|
+
// Strong signatures fire at any length up to ~1500 (refusals are short).
|
|
67
|
+
if (s.length <= 1500) for (const re of REFUSAL_SIGNATURES) if (re.test(s)) return true;
|
|
68
|
+
// Short + soft decline hints → also a refusal. Short because a real coding answer
|
|
69
|
+
// is long / full of code; a brief prose reply that declines is a refusal.
|
|
70
|
+
if (s.length <= 600) {
|
|
71
|
+
const hasCode = /```|\bfunction\b|\bconst \b|\bimport \b|=>|\bdef \b|;\n/.test(s);
|
|
72
|
+
if (!hasCode) for (const re of DECLINE_HINTS) if (re.test(s)) return true;
|
|
73
|
+
}
|
|
50
74
|
return false;
|
|
51
75
|
}
|
|
52
76
|
|
package/src/server.js
CHANGED
|
@@ -7,11 +7,27 @@
|
|
|
7
7
|
// localhost only — nothing here should be reachable off the machine.
|
|
8
8
|
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
|
+
import { appendFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
10
12
|
import { handle, handleStream, createMasker, privateReply } from "./index.js";
|
|
11
13
|
import { forward } from "./providers.js";
|
|
12
14
|
import { dialect, rewriteText } from "./wire.js";
|
|
13
15
|
import { isRefusal } from "./route.js";
|
|
14
16
|
import { writeReceipt } from "./receipt.js";
|
|
17
|
+
import { HOME_DIR } from "./config.js";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
let VERSION = "?";
|
|
22
|
+
try { VERSION = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version; } catch {}
|
|
23
|
+
export { VERSION };
|
|
24
|
+
|
|
25
|
+
// Ground-truth log of every streaming routing decision, so a real Claude Code run
|
|
26
|
+
// can be diagnosed from actual traffic instead of guesses. Never contains raw PII
|
|
27
|
+
// beyond the frontier response prefix (already sent to the provider).
|
|
28
|
+
function logDecision(o) {
|
|
29
|
+
try { appendFileSync(join(HOME_DIR, "debug.log"), JSON.stringify({ ts: new Date().toISOString(), ...o }) + "\n"); } catch {}
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
/** Pull assistant text out of an Anthropic SSE chunk (text_delta events). */
|
|
17
33
|
function extractSseText(chunk) {
|
|
@@ -54,7 +70,7 @@ export function startServer(cfg) {
|
|
|
54
70
|
const server = createServer(async (req, res) => {
|
|
55
71
|
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
56
72
|
|
|
57
|
-
if (req.method === "GET" && req.url === "/health") return send(200, { ok: true, proxy: "solrouter", mode: cfg.mode });
|
|
73
|
+
if (req.method === "GET" && req.url === "/health") return send(200, { ok: true, proxy: "solrouter", version: VERSION, mode: cfg.mode });
|
|
58
74
|
|
|
59
75
|
// Transparent passthrough for other Anthropic endpoints the client probes —
|
|
60
76
|
// notably /v1/messages/count_tokens, which Claude Code calls before every
|
|
@@ -117,9 +133,14 @@ export function startServer(cfg) {
|
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
let leg = s.leg, refused = false, model = s.model;
|
|
136
|
+
logDecision({ event: "stream-decision", reqModel: body.model, mode: s.mode,
|
|
137
|
+
committed, refusalLikely, isRefusalFinal: isRefusal(acc), respLen: acc.length,
|
|
138
|
+
respPrefix: acc.slice(0, 400) });
|
|
120
139
|
if (!committed && s.mode === "auto" && refusalLikely) {
|
|
121
140
|
// Streamed refusal → reroute to the uncensored private leg.
|
|
122
141
|
const pr = await privateReply(cfg, s.maskedPrompt);
|
|
142
|
+
logDecision({ event: "reroute", privateOk: pr.ok, privateStatus: pr.status,
|
|
143
|
+
privateModel: cfg.private.model, replyLen: (pr.reply || "").length });
|
|
123
144
|
if (pr.ok && pr.reply) {
|
|
124
145
|
leg = "private"; refused = true; model = cfg.private.model;
|
|
125
146
|
const restored = restorer ? s.masker.unmask(pr.reply) : pr.reply;
|
|
@@ -169,7 +190,7 @@ export function startServer(cfg) {
|
|
|
169
190
|
}
|
|
170
191
|
});
|
|
171
192
|
server.listen(cfg.port, "127.0.0.1", () => {
|
|
172
|
-
console.log(`SolRouter proxy on http://127.0.0.1:${cfg.port}`);
|
|
193
|
+
console.log(`SolRouter proxy v${VERSION} on http://127.0.0.1:${cfg.port}`);
|
|
173
194
|
console.log(` mode=${cfg.mode} frontier=${cfg.frontier.model || "(client's model)"} [${cfg.frontier.auth || "passthrough"}] private=${cfg.private.model}`);
|
|
174
195
|
console.log(` point your tool's base URL here:`);
|
|
175
196
|
console.log(` OpenAI-style: http://127.0.0.1:${cfg.port}/v1`);
|