solrouter 0.4.5 → 0.6.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 +49 -10
- package/SKILL.md +107 -0
- package/SPEC.md +8 -5
- package/TESTING.md +9 -8
- package/bin/proxy.js +91 -4
- package/config.example.json +3 -5
- package/package.json +6 -2
- package/src/auth.js +42 -8
- package/src/config.js +10 -8
- package/src/index.js +18 -14
- package/src/install.js +115 -0
- package/src/mask.js +9 -3
- package/src/providers.js +20 -23
- package/src/pseudonymise.js +0 -0
- package/src/receipt.js +7 -2
- package/src/route.js +10 -2
- package/src/secrets.js +12 -6
- package/src/server.js +101 -28
- package/src/surrogates.js +12 -8
- package/src/wire.js +123 -12
package/src/install.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// install.js — make the proxy the default for EVERY Claude Code session, so there's
|
|
2
|
+
// no "which window routes through it" footgun. Two pieces:
|
|
3
|
+
// 1. a macOS LaunchAgent that keeps the proxy running on a fixed port (auto-start
|
|
4
|
+
// at login, auto-restart on crash);
|
|
5
|
+
// 2. ~/.claude/settings.json `env` pointing all Claude Code at that port (+ an API
|
|
6
|
+
// key, which Claude Code requires to use a custom base URL — see the README).
|
|
7
|
+
//
|
|
8
|
+
// `uninstall` reverses both, touching only the keys we added.
|
|
9
|
+
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from "node:fs";
|
|
14
|
+
import { loadConfig, HOME_DIR } from "./config.js";
|
|
15
|
+
|
|
16
|
+
const LABEL = "com.solrouter.proxy";
|
|
17
|
+
const PLIST = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
18
|
+
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
|
|
19
|
+
const MARK = "__solrouter"; // marks which env keys we own, for a clean uninstall
|
|
20
|
+
|
|
21
|
+
async function freePort(start) {
|
|
22
|
+
const net = await import("node:net");
|
|
23
|
+
for (let p = start; p < start + 25; p++) {
|
|
24
|
+
const ok = await new Promise(res => {
|
|
25
|
+
const s = net.createServer();
|
|
26
|
+
s.once("error", () => res(false));
|
|
27
|
+
s.once("listening", () => s.close(() => res(true)));
|
|
28
|
+
s.listen(p, "127.0.0.1");
|
|
29
|
+
});
|
|
30
|
+
if (ok) return p;
|
|
31
|
+
}
|
|
32
|
+
return start;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function plistXml({ node, binPath, port }) {
|
|
36
|
+
const log = join(HOME_DIR, "daemon.log");
|
|
37
|
+
const args = [node, binPath, "start", "--port", String(port)]
|
|
38
|
+
.map(a => ` <string>${a}</string>`).join("\n");
|
|
39
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key><string>${LABEL}</string>
|
|
44
|
+
<key>ProgramArguments</key>
|
|
45
|
+
<array>
|
|
46
|
+
${args}
|
|
47
|
+
</array>
|
|
48
|
+
<key>RunAtLoad</key><true/>
|
|
49
|
+
<key>KeepAlive</key><true/>
|
|
50
|
+
<key>StandardOutPath</key><string>${log}</string>
|
|
51
|
+
<key>StandardErrorPath</key><string>${log}</string>
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Merge our env into ~/.claude/settings.json, preserving everything else. */
|
|
58
|
+
function writeClaudeEnv(port, key) {
|
|
59
|
+
let s = {};
|
|
60
|
+
try { s = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf8")); } catch {}
|
|
61
|
+
if (!existsSync(join(homedir(), ".claude"))) mkdirSync(join(homedir(), ".claude"), { recursive: true });
|
|
62
|
+
s.env = s.env || {};
|
|
63
|
+
s.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}`;
|
|
64
|
+
s.env.ANTHROPIC_API_KEY = key;
|
|
65
|
+
s.env[MARK] = "1"; // our marker
|
|
66
|
+
writeFileSync(CLAUDE_SETTINGS, JSON.stringify(s, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function removeClaudeEnv() {
|
|
70
|
+
let s;
|
|
71
|
+
try { s = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf8")); } catch { return; }
|
|
72
|
+
if (s.env?.[MARK]) {
|
|
73
|
+
delete s.env.ANTHROPIC_BASE_URL;
|
|
74
|
+
delete s.env.ANTHROPIC_API_KEY;
|
|
75
|
+
delete s.env[MARK];
|
|
76
|
+
if (Object.keys(s.env).length === 0) delete s.env;
|
|
77
|
+
writeFileSync(CLAUDE_SETTINGS, JSON.stringify(s, null, 2));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a stable path to this proxy's bin (npx caches are ephemeral → warn). */
|
|
82
|
+
function resolveBin() {
|
|
83
|
+
const p = realpathSync(process.argv[1]);
|
|
84
|
+
return { binPath: p, ephemeral: /[/\\]_npx[/\\]/.test(p) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function install() {
|
|
88
|
+
if (process.platform !== "darwin")
|
|
89
|
+
return { ok: false, error: "`install` is macOS-only (LaunchAgent + launchctl). On Linux/Windows, run `solrouter start` and point your tools at http://127.0.0.1:<port>." };
|
|
90
|
+
const cfg = loadConfig();
|
|
91
|
+
const key = cfg.frontier.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
92
|
+
if (!key) return { ok: false, error: "No Anthropic API key configured. Run `solrouter setup` first." };
|
|
93
|
+
|
|
94
|
+
const { binPath, ephemeral } = resolveBin();
|
|
95
|
+
if (ephemeral) return { ok: false, error: "Run `npm i -g solrouter` first — an npx cache path can't back a persistent service." };
|
|
96
|
+
|
|
97
|
+
const port = await freePort(cfg.port || 8787);
|
|
98
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
99
|
+
if (!existsSync(join(homedir(), "Library", "LaunchAgents"))) mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
100
|
+
|
|
101
|
+
writeFileSync(PLIST, plistXml({ node: process.execPath, binPath, port }));
|
|
102
|
+
try { execSync(`launchctl unload "${PLIST}" 2>/dev/null`); } catch {}
|
|
103
|
+
execSync(`launchctl load "${PLIST}"`);
|
|
104
|
+
writeClaudeEnv(port, key);
|
|
105
|
+
return { ok: true, port, plist: PLIST };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function uninstall() {
|
|
109
|
+
try { execSync(`launchctl unload "${PLIST}" 2>/dev/null`); } catch {}
|
|
110
|
+
if (existsSync(PLIST)) rmSync(PLIST);
|
|
111
|
+
removeClaudeEnv();
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default { install, uninstall };
|
package/src/mask.js
CHANGED
|
@@ -24,12 +24,15 @@ export function createMasker(opts = {}) {
|
|
|
24
24
|
const seen = []; // every entity masked this session (for the receipt)
|
|
25
25
|
|
|
26
26
|
function surrogate(label, value) {
|
|
27
|
-
|
|
27
|
+
// Key by label:value, not value alone — the same literal string under two
|
|
28
|
+
// labels (a loose PHONE vs a guessed PERSON, say) must not reuse one shape.
|
|
29
|
+
const key = label + ":" + value;
|
|
30
|
+
if (fwd.has(key)) return fwd.get(key);
|
|
28
31
|
let s = surrogateFor(label, value);
|
|
29
32
|
// Deterministic collision (two different originals → same fake): salt until unique.
|
|
30
33
|
let salt = 0;
|
|
31
34
|
while ((used.has(s) && rev.get(s) !== value)) s = surrogateFor(label, value + "#" + ++salt);
|
|
32
|
-
fwd.set(
|
|
35
|
+
fwd.set(key, s); rev.set(s, value); used.add(s);
|
|
33
36
|
return s;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -49,7 +52,7 @@ export function createMasker(opts = {}) {
|
|
|
49
52
|
return { text: out, entities };
|
|
50
53
|
},
|
|
51
54
|
|
|
52
|
-
/** Restore a complete string: surrogates → originals,
|
|
55
|
+
/** Restore a complete string: surrogates → originals (exact, then case-insensitive). */
|
|
53
56
|
unmask(text) {
|
|
54
57
|
return restore(String(text ?? ""), rev);
|
|
55
58
|
},
|
|
@@ -101,6 +104,9 @@ function streamingRestorer(rev) {
|
|
|
101
104
|
for (const k of keys) {
|
|
102
105
|
const max = Math.min(k.length - 1, buf.length); // proper prefix only
|
|
103
106
|
for (let n = max; n > hold; n--) {
|
|
107
|
+
// Case-sensitive on purpose: a case-insensitive compare holds back a self-
|
|
108
|
+
// overlapping surrogate (e.g. "Tomas Cato": tail "to" == prefix "To") even when
|
|
109
|
+
// the surrogate is already complete in buf, splitting it so restore() misses it.
|
|
104
110
|
if (buf.slice(buf.length - n) === k.slice(0, n)) { hold = n; break; }
|
|
105
111
|
}
|
|
106
112
|
}
|
package/src/providers.js
CHANGED
|
@@ -48,13 +48,9 @@ function authHeaders(leg, clientHeaders = {}) {
|
|
|
48
48
|
return h;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// 2. pass-through (default): forward the client's own auth
|
|
52
|
-
|
|
53
|
-
for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k])
|
|
54
|
-
if (!forwarded) {
|
|
55
|
-
const auth = loadAuth();
|
|
56
|
-
if (auth.access_token) h["authorization"] = `Bearer ${auth.access_token}`;
|
|
57
|
-
}
|
|
51
|
+
// 2. pass-through (default): forward the client's own auth. Never fall back to
|
|
52
|
+
// the saved SolRouter key: this leg goes to a third-party provider.
|
|
53
|
+
for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k]) h[k] = clientHeaders[k];
|
|
58
54
|
if (leg.dialect === "anthropic" && !h["anthropic-version"]) h["anthropic-version"] = "2023-06-01";
|
|
59
55
|
return h;
|
|
60
56
|
}
|
|
@@ -92,23 +88,24 @@ export async function callLeg(leg, body, clientHeaders) {
|
|
|
92
88
|
}
|
|
93
89
|
|
|
94
90
|
/**
|
|
95
|
-
* The private/uncensored leg:
|
|
96
|
-
*
|
|
97
|
-
*
|
|
91
|
+
* The private/uncensored leg: SolRouter's open-weight model inside the TEE.
|
|
92
|
+
* @solrouter/sdk encrypts the (already pseudonymised) prompt on this machine and
|
|
93
|
+
* POSTs only ciphertext to {backend}/tee/process; the reply comes back encrypted.
|
|
94
|
+
* The SolRouter backend never sees the prompt. Returns { ok, status, reply }.
|
|
98
95
|
*/
|
|
99
|
-
export async function
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
export async function callSolrouterPrivate(leg, prompt) {
|
|
97
|
+
const key = leg.apiKey || loadAuth().access_token;
|
|
98
|
+
if (!key) return { ok: false, status: 401, reply: "", error: "no SolRouter API key — run `solrouter login`" };
|
|
99
|
+
const model = leg.model || "qwen3.8:27b";
|
|
100
|
+
try {
|
|
101
|
+
// Lazy: the Arcium crypto tree is heavy, and only this leg needs it.
|
|
102
|
+
const { SolRouter } = await import("@solrouter/sdk");
|
|
103
|
+
const client = new SolRouter({ apiKey: key, baseUrl: leg.baseUrl.replace(/\/$/, ""), encrypted: true });
|
|
104
|
+
const r = await client.chat(prompt, { model: model.startsWith("nosana:") ? model : `nosana:${model}` });
|
|
105
|
+
return { ok: true, status: 200, reply: r.message ?? "" };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return { ok: false, status: 502, reply: "", error: e instanceof Error ? e.message : String(e) };
|
|
108
|
+
}
|
|
112
109
|
}
|
|
113
110
|
|
|
114
111
|
/**
|
package/src/pseudonymise.js
CHANGED
|
Binary file
|
package/src/receipt.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// receipt.js — one hash-chained JSONL line per call. What was detected, what was
|
|
2
|
-
// masked, which leg ran it, and a hash of
|
|
2
|
+
// masked, which leg ran it, and a hash of the masked payload that leg was given.
|
|
3
|
+
// For the frontier leg that is the request body on the wire. For the sealed
|
|
4
|
+
// private leg it is the masked prompt before @solrouter/sdk encrypts it, because
|
|
5
|
+
// the ciphertext uses a random nonce per call and its hash would prove nothing.
|
|
3
6
|
// The receipt never contains raw values or the map — only labels, counts, and
|
|
4
7
|
// hashes — so the audit log itself is safe to keep and to show an auditor.
|
|
5
8
|
|
|
6
9
|
import { createHash } from "node:crypto";
|
|
7
|
-
import { appendFileSync, readFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
8
12
|
|
|
9
13
|
const sha256 = s => createHash("sha256").update(String(s)).digest("hex");
|
|
10
14
|
|
|
@@ -40,6 +44,7 @@ export function writeReceipt(path, entry) {
|
|
|
40
44
|
};
|
|
41
45
|
const hash = sha256(prev + JSON.stringify(body));
|
|
42
46
|
const line = JSON.stringify({ ...body, hash });
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true }); // first run: ~/.solrouter may not exist yet
|
|
43
48
|
appendFileSync(path, line + "\n");
|
|
44
49
|
return { ...body, hash };
|
|
45
50
|
}
|
package/src/route.js
CHANGED
|
@@ -63,12 +63,20 @@ const DECLINE_HINTS = [
|
|
|
63
63
|
export function isRefusal(text) {
|
|
64
64
|
const s = String(text ?? "").trim();
|
|
65
65
|
if (!s) return false;
|
|
66
|
-
// Strong signatures fire at any length up to ~1500 (refusals are short).
|
|
66
|
+
// Strong signatures fire at any length up to ~1500 (refusals are short). Raising
|
|
67
|
+
// this cap misroutes long genuine answers that merely quote "I can't help…", so
|
|
68
|
+
// it stays at 1500: a false reroute to the weaker leg is worse than missing a rare
|
|
69
|
+
// 1500+ char refusal (which the reactive path still catches at the next turn).
|
|
67
70
|
if (s.length <= 1500) for (const re of REFUSAL_SIGNATURES) if (re.test(s)) return true;
|
|
68
71
|
// Short + soft decline hints → also a refusal. Short because a real coding answer
|
|
69
72
|
// is long / full of code; a brief prose reply that declines is a refusal.
|
|
70
73
|
if (s.length <= 600) {
|
|
71
|
-
|
|
74
|
+
// "Looks like code" = a fenced block, structural punctuation, an indented line,
|
|
75
|
+
// or SEVERAL code keywords — NOT a single keyword, so a plain refusal that just
|
|
76
|
+
// says the word "function" is no longer misread as a code answer.
|
|
77
|
+
const hasCode = /```/.test(s)
|
|
78
|
+
|| /[{};]\s*$|\)\s*\{|=>\s|^\s{2,}\S/m.test(s)
|
|
79
|
+
|| (s.match(/\b(?:function|const|let|import|export|def|class|return|await)\b/g) || []).length >= 2;
|
|
72
80
|
if (!hasCode) for (const re of DECLINE_HINTS) if (re.test(s)) return true;
|
|
73
81
|
}
|
|
74
82
|
return false;
|
package/src/secrets.js
CHANGED
|
@@ -30,16 +30,22 @@ export const SECRET_PATTERNS = [
|
|
|
30
30
|
// Bearer header values.
|
|
31
31
|
{ label: "BEARER", re: /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*\b/ },
|
|
32
32
|
|
|
33
|
-
// Internal hostnames a model has no business seeing.
|
|
33
|
+
// Internal hostnames a model has no business seeing. Case-SENSITIVE on purpose:
|
|
34
|
+
// /i made it match PascalCase member access in code (cfg.Internal, Acme.Corp) and
|
|
35
|
+
// corrupt it. Quantifiers are BOUNDED (DNS labels ≤63 chars, ≤8 levels) so each
|
|
36
|
+
// start position does O(1) work — an unbounded run went quadratic on dotted input
|
|
37
|
+
// (a.a.a…), blocking the event loop for seconds on a ~32KB string.
|
|
34
38
|
{ label: "INTERNAL_HOST",
|
|
35
|
-
re: /\b[a-z0-9][a-z0-9
|
|
39
|
+
re: /\b[a-z0-9][a-z0-9-]{0,62}(?:\.[a-z0-9-]{1,63}){0,8}\.(?:internal|corp|local|intranet|lan|svc\.cluster\.local)\b/ },
|
|
36
40
|
|
|
37
|
-
// "SECRET=…", "api_key: …", "token=…" — a labelled assignment.
|
|
38
|
-
//
|
|
41
|
+
// "SECRET=…", "api_key: …", "token=…" — a labelled assignment. A variable-length
|
|
42
|
+
// lookbehind matches the label + separator but keeps them OUT of the match, so the
|
|
43
|
+
// span is the value ALONE — the label never enters the surrogate and its length
|
|
44
|
+
// can't shift which characters get preserved. Skips obvious placeholders.
|
|
39
45
|
{ label: "ASSIGNED_SECRET",
|
|
40
|
-
re:
|
|
46
|
+
re: /(?<=\b(?:[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|ACCESS_KEY))\b\s*[:=]\s*["']?)[^\s"',}]{8,}/i,
|
|
41
47
|
check: v => {
|
|
42
|
-
const val =
|
|
48
|
+
const val = v.replace(/["']/g, "").trim();
|
|
43
49
|
return !/^(?:your|my|the|example|changeme|placeholder|xxx+|<[^>]+>|\.\.\.)/i.test(val)
|
|
44
50
|
&& !/^\$\{?[A-Z0-9_]+\}?$/.test(val); // not itself an env-var reference
|
|
45
51
|
} },
|
package/src/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { appendFileSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { handle, handleStream, createMasker, privateReply } from "./index.js";
|
|
13
13
|
import { forward } from "./providers.js";
|
|
14
|
-
import { dialect, rewriteText } from "./wire.js";
|
|
14
|
+
import { dialect, rewriteText, extractResponseText } from "./wire.js";
|
|
15
15
|
import { isRefusal } from "./route.js";
|
|
16
16
|
import { writeReceipt } from "./receipt.js";
|
|
17
17
|
import { HOME_DIR } from "./config.js";
|
|
@@ -29,14 +29,20 @@ function logDecision(o) {
|
|
|
29
29
|
try { appendFileSync(join(HOME_DIR, "debug.log"), JSON.stringify({ ts: new Date().toISOString(), ...o }) + "\n"); } catch {}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Pull assistant text out of an
|
|
33
|
-
function extractSseText(chunk) {
|
|
32
|
+
/** Pull assistant text out of an SSE chunk, in whichever dialect the leg streams. */
|
|
33
|
+
export function extractSseText(chunk, kind = "anthropic") {
|
|
34
34
|
let out = "";
|
|
35
35
|
for (const line of String(chunk).split("\n")) {
|
|
36
36
|
if (!line.startsWith("data:")) continue;
|
|
37
|
+
const payload = line.slice(5).trim();
|
|
38
|
+
if (payload === "[DONE]") continue;
|
|
37
39
|
try {
|
|
38
|
-
const d = JSON.parse(
|
|
39
|
-
if (
|
|
40
|
+
const d = JSON.parse(payload);
|
|
41
|
+
if (kind === "openai") {
|
|
42
|
+
for (const ch of d?.choices || []) out += ch?.delta?.content || "";
|
|
43
|
+
} else if (d?.type === "content_block_delta" && d.delta?.type === "text_delta") {
|
|
44
|
+
out += d.delta.text || "";
|
|
45
|
+
}
|
|
40
46
|
} catch {}
|
|
41
47
|
}
|
|
42
48
|
return out;
|
|
@@ -55,12 +61,39 @@ function synthAnthropicSse(text, model) {
|
|
|
55
61
|
);
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
/** Build a complete OpenAI-format SSE stream from a plain text answer. */
|
|
65
|
+
function synthOpenAiSse(text, model) {
|
|
66
|
+
const ev = d => `data: ${JSON.stringify(d)}\n\n`;
|
|
67
|
+
const base = { id: "chatcmpl-proxy", object: "chat.completion.chunk", model: model || "solrouter" };
|
|
68
|
+
return (
|
|
69
|
+
ev({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: String(text) }, finish_reason: null }] }) +
|
|
70
|
+
ev({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }) +
|
|
71
|
+
"data: [DONE]\n\n"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Synthesise an SSE answer in the client's dialect (what the caller will parse). */
|
|
76
|
+
const synthSse = (kind, text, model) => kind === "openai" ? synthOpenAiSse(text, model) : synthAnthropicSse(text, model);
|
|
77
|
+
|
|
78
|
+
export function readBody(req) {
|
|
59
79
|
return new Promise((resolve, reject) => {
|
|
60
|
-
let data = "";
|
|
61
|
-
|
|
62
|
-
req.on("
|
|
63
|
-
|
|
80
|
+
let data = "", done = false;
|
|
81
|
+
const settle = (fn, v) => { if (!done) { done = true; fn(v); } };
|
|
82
|
+
req.on("data", c => {
|
|
83
|
+
data += c;
|
|
84
|
+
if (data.length > 25 * 1024 * 1024) {
|
|
85
|
+
// pause (don't destroy) the socket so the handler can still write a 413 back;
|
|
86
|
+
// destroy() tears down the socket and the client gets a reset, not a status.
|
|
87
|
+
req.pause();
|
|
88
|
+
settle(reject, Object.assign(new Error("request body too large"), { code: "BODY_TOO_LARGE" }));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
req.on("end", () => { try { settle(resolve, data ? JSON.parse(data) : {}); } catch (e) { settle(reject, e); } });
|
|
92
|
+
req.on("error", e => settle(reject, e));
|
|
93
|
+
// destroy() fires 'aborted'/'close', not 'error' — settle here so an oversized or
|
|
94
|
+
// truncated request rejects instead of leaving the handler awaiting forever.
|
|
95
|
+
req.on("aborted", () => settle(reject, new Error("request aborted")));
|
|
96
|
+
req.on("close", () => settle(reject, new Error("connection closed before body completed")));
|
|
64
97
|
});
|
|
65
98
|
}
|
|
66
99
|
|
|
@@ -72,17 +105,30 @@ export function startServer(cfg) {
|
|
|
72
105
|
|
|
73
106
|
if (req.method === "GET" && req.url === "/health") return send(200, { ok: true, proxy: "solrouter", version: VERSION, mode: cfg.mode });
|
|
74
107
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
108
|
+
// Other /v1 endpoints the client probes. Claude Code calls
|
|
109
|
+
// /v1/messages/count_tokens before every request and treats a 404 as "model
|
|
110
|
+
// unavailable". Only shapes the masker understands may leave the machine:
|
|
111
|
+
// body-less GETs and count_tokens (a messages body). Anything else (e.g.
|
|
112
|
+
// /v1/responses) would go out unmasked, so it is refused. In private mode
|
|
113
|
+
// nothing goes to the frontier at all.
|
|
78
114
|
if (/^\/v1\//.test(req.url) && !/^\/(v1\/chat\/completions|v1\/messages)$/.test(req.url)) {
|
|
115
|
+
const path = req.url.split("?")[0];
|
|
116
|
+
const h = lower(req.headers);
|
|
117
|
+
const privateOnly = h["x-route"] === "private" || (h["x-mode"] || cfg.mode) === "private";
|
|
118
|
+
const countTokens = req.method === "POST" && path === "/v1/messages/count_tokens";
|
|
79
119
|
let body;
|
|
80
120
|
if (req.method === "POST") {
|
|
81
|
-
try { body = await readBody(req); } catch { return send(400, { error: "invalid JSON body" }); }
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
121
|
+
try { body = await readBody(req); } catch (e) { return send(e?.code === "BODY_TOO_LARGE" ? 413 : 400, { error: e?.code === "BODY_TOO_LARGE" ? "request body too large" : "invalid JSON body" }); }
|
|
122
|
+
}
|
|
123
|
+
if (privateOnly) {
|
|
124
|
+
if (countTokens) return send(200, { input_tokens: Math.ceil(JSON.stringify(body?.messages ?? "").length / 4) }); // rough local estimate
|
|
125
|
+
if (req.method === "GET" && path === "/v1/models") return send(200, { object: "list", data: [{ id: cfg.private.model, object: "model" }] });
|
|
126
|
+
return send(404, { error: `${path} is not available in private mode` });
|
|
127
|
+
}
|
|
128
|
+
if (req.method !== "GET" && !countTokens) return send(404, { error: `${path} is not supported by the solrouter proxy` });
|
|
129
|
+
if (body && cfg.masking?.enabled !== false) {
|
|
130
|
+
const m = createMasker(cfg.masking);
|
|
131
|
+
body = rewriteText(body, dialect(body, req.url), s => m.mask(s).text);
|
|
86
132
|
}
|
|
87
133
|
try {
|
|
88
134
|
const r = await forward(cfg.frontier, { method: req.method, path: req.url, body, clientHeaders: lower(req.headers) });
|
|
@@ -97,15 +143,21 @@ export function startServer(cfg) {
|
|
|
97
143
|
}
|
|
98
144
|
|
|
99
145
|
let body;
|
|
100
|
-
try { body = await readBody(req); } catch { return send(400, { error: "invalid JSON body" }); }
|
|
146
|
+
try { body = await readBody(req); } catch (e) { return send(e?.code === "BODY_TOO_LARGE" ? 413 : 400, { error: e?.code === "BODY_TOO_LARGE" ? "request body too large" : "invalid JSON body" }); }
|
|
101
147
|
const ctx = { path: req.url, headers: lower(req.headers) };
|
|
102
148
|
|
|
103
149
|
try {
|
|
104
150
|
if (body.stream) {
|
|
105
151
|
const s = await handleStream(body, ctx, cfg);
|
|
106
152
|
// Private leg can't stream yet — it returns a prebuilt body (receipt
|
|
107
|
-
// already written inside handle()).
|
|
108
|
-
|
|
153
|
+
// already written inside handle()). The client asked for a stream, so its
|
|
154
|
+
// parser expects SSE: replay the answer as one SSE stream in its dialect.
|
|
155
|
+
if (s.prebuilt) {
|
|
156
|
+
if (s.status !== 200) return send(s.status || 502, s.json);
|
|
157
|
+
const kind = dialect(body, req.url);
|
|
158
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
|
159
|
+
return res.end(synthSse(kind, extractResponseText(s.json, kind), s.json?.model));
|
|
160
|
+
}
|
|
109
161
|
if (!s.ok || !s.stream) return send(s.status || 502, { error: "upstream error", leg: s.leg });
|
|
110
162
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
111
163
|
const restorer = cfg.masking?.enabled === false ? null : s.masker.streamingUnmasker();
|
|
@@ -118,12 +170,16 @@ export function startServer(cfg) {
|
|
|
118
170
|
// stream live. If the whole short response is a refusal and mode is auto,
|
|
119
171
|
// we discard it and reroute to the private/uncensored leg.
|
|
120
172
|
const COMMIT_AT = 380; // chars of answer text that means "real answer"
|
|
173
|
+
// Parse the frontier's SSE in ITS dialect — an OpenAI frontier streams
|
|
174
|
+
// choices[].delta.content, not Anthropic text_delta, so a fixed parser would
|
|
175
|
+
// read "" from every chunk and never detect a refusal or commit to streaming.
|
|
176
|
+
const frontierKind = cfg.frontier?.dialect || s.kind;
|
|
121
177
|
let committed = false, held = "", acc = "", refusalLikely = false;
|
|
122
178
|
for (;;) {
|
|
123
179
|
const { done, value } = await reader.read();
|
|
124
180
|
if (done) break;
|
|
125
181
|
const chunk = dec.decode(value, { stream: true });
|
|
126
|
-
acc += extractSseText(chunk);
|
|
182
|
+
acc += extractSseText(chunk, frontierKind);
|
|
127
183
|
if (committed) { emit(chunk); continue; }
|
|
128
184
|
held += chunk;
|
|
129
185
|
// Lock onto a refusal as soon as its opening matches — then never commit,
|
|
@@ -144,7 +200,7 @@ export function startServer(cfg) {
|
|
|
144
200
|
if (pr.ok && pr.reply) {
|
|
145
201
|
leg = "private"; refused = true; model = cfg.private.model;
|
|
146
202
|
const restored = restorer ? s.masker.unmask(pr.reply) : pr.reply;
|
|
147
|
-
res.write(
|
|
203
|
+
res.write(synthSse(s.kind, restored, model));
|
|
148
204
|
res.end();
|
|
149
205
|
if (cfg.receipt?.enabled) writeReceipt(cfg.receipt.path, {
|
|
150
206
|
leg, reason: "reactive-refusal", model, entities: s.masker.entities(), refused: true, sentToProvider: s.sent });
|
|
@@ -153,9 +209,9 @@ export function startServer(cfg) {
|
|
|
153
209
|
// Reroute was needed but the uncensored leg failed — say so LOUDLY instead
|
|
154
210
|
// of silently relaying Claude's refusal (this is almost always "not logged
|
|
155
211
|
// in" / an invalid SolRouter key).
|
|
156
|
-
res.write(
|
|
212
|
+
res.write(synthSse(s.kind,
|
|
157
213
|
`⚠️ [solrouter] Claude refused this, and the uncensored leg couldn't be reached (${pr.status || "error"}). ` +
|
|
158
|
-
`Run \`solrouter login
|
|
214
|
+
`Run \`solrouter login\`, approve in the browser, then retry.`,
|
|
159
215
|
cfg.private.model));
|
|
160
216
|
res.end();
|
|
161
217
|
if (cfg.receipt?.enabled) writeReceipt(cfg.receipt.path, {
|
|
@@ -180,10 +236,27 @@ export function startServer(cfg) {
|
|
|
180
236
|
}
|
|
181
237
|
});
|
|
182
238
|
|
|
183
|
-
server.on("error", (e) => {
|
|
239
|
+
server.on("error", async (e) => {
|
|
184
240
|
if (e.code === "EADDRINUSE") {
|
|
185
|
-
//
|
|
186
|
-
|
|
241
|
+
// Something is already on this port. Only claim "reusing" if it is actually
|
|
242
|
+
// a SolRouter proxy — otherwise we'd be routing the user's traffic to an
|
|
243
|
+
// unrelated process while reporting success.
|
|
244
|
+
let isOurs = false;
|
|
245
|
+
try {
|
|
246
|
+
const ctrl = new AbortController();
|
|
247
|
+
const t = setTimeout(() => ctrl.abort(), 1000);
|
|
248
|
+
const r = await fetch(`http://127.0.0.1:${cfg.port}/health`, { signal: ctrl.signal });
|
|
249
|
+
const j = await r.json().catch(() => ({}));
|
|
250
|
+
clearTimeout(t);
|
|
251
|
+
isOurs = r.ok && j?.proxy === "solrouter";
|
|
252
|
+
} catch { /* unreachable or not JSON — not ours */ }
|
|
253
|
+
if (isOurs) {
|
|
254
|
+
console.log(`SolRouter proxy already running on http://127.0.0.1:${cfg.port} — reusing it.`);
|
|
255
|
+
} else {
|
|
256
|
+
console.error(`Port ${cfg.port} is in use by another process (not a SolRouter proxy).`);
|
|
257
|
+
console.error(`Free it, or start on a different port: solrouter start --port <n>`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
187
260
|
} else {
|
|
188
261
|
console.error("proxy server error:", e.message);
|
|
189
262
|
process.exit(1);
|
package/src/surrogates.js
CHANGED
|
@@ -74,16 +74,20 @@ function fakeCard(r) {
|
|
|
74
74
|
return [...body, check].join("").replace(/(\d{4})(?=\d)/g, "$1 ");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
77
|
+
// Recognised PUBLIC key prefixes — non-secret markers, safe to keep so the model
|
|
78
|
+
// still sees the type (sk-proj-, sk-ant-, AKIA, ghp_, …). Everything else keeps
|
|
79
|
+
// NO real characters: the leading bytes of a raw secret are still secret.
|
|
80
|
+
const KEY_PREFIX = /^(?:sk-ant-|sk-proj-|sk-|xox[baprs]-|gh[pousr]_|AKIA|ASIA|AIza|[rs]k_(?:live|test)_|eyJ)/;
|
|
81
|
+
|
|
82
|
+
// Keep a secret's shape (length + non-alphanumeric layout) but randomise the body,
|
|
83
|
+
// so the model sees a plausible key and never the real one. Only a recognised
|
|
84
|
+
// public prefix is preserved; an unlabelled secret keeps nothing.
|
|
79
85
|
function fakeLikeShape(value, r) {
|
|
80
86
|
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return value.replace(/[A-Za-z0-9]/g, ch =>
|
|
86
|
-
seen++ < keep ? ch : alnum[r.int(alnum.length)]);
|
|
87
|
+
const pfx = value.match(KEY_PREFIX);
|
|
88
|
+
const keep = pfx ? pfx[0].length : 0; // keep by string offset, not alnum count
|
|
89
|
+
return value.replace(/[A-Za-z0-9]/g, (ch, idx) =>
|
|
90
|
+
idx < keep ? ch : alnum[r.int(alnum.length)]);
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
const GENERATORS = {
|