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/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
- if (fwd.has(value)) return fwd.get(value);
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(value, s); rev.set(s, value); used.add(s);
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, fuzzy for mangled cases. */
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 too
52
- let forwarded = false;
53
- for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k]) { h[k] = clientHeaders[k]; forwarded = true; }
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: the SolRouter backend's /agent route. Takes a plain
96
- * prompt + a sk_solrouter_ key (stateless for API-key users), returns { reply }.
97
- * The prompt is already pseudonymised upstream.
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 callSolrouterAgent(leg, prompt) {
100
- const auth = loadAuth();
101
- const key = leg.apiKey || auth.access_token;
102
- if (!key) return { ok: false, status: 401, reply: "", error: "no SolRouter API key — run `login --token sk_solrouter_...`" };
103
- const base = leg.baseUrl.replace(/\/$/, "");
104
- const res = await fetch(`${base}/agent`, {
105
- method: "POST",
106
- headers: { "content-type": "application/json", "authorization": `Bearer ${key}` },
107
- body: JSON.stringify({ prompt, model: leg.model || "qwen3.8:27b", useTools: false }),
108
- });
109
- let json = null;
110
- try { json = await res.json(); } catch {}
111
- return { ok: res.ok && json?.success !== false, status: res.status, reply: json?.reply ?? "", raw: json };
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
  /**
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 what the provider actually received.
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
- const hasCode = /```|\bfunction\b|\bconst \b|\bimport \b|=>|\bdef \b|;\n/.test(s);
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.-]*\.(?:internal|corp|local|intranet|lan|svc\.cluster\.local)\b/i },
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. Captures the
38
- // value; validated to skip obvious placeholders like "your-key-here".
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: /\b(?:[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|ACCESS_KEY))\b\s*[:=]\s*["']?([^\s"',}]{8,})/i,
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 = (v.split(/[:=]/).slice(1).join("=") || v).replace(/["']/g, "").trim();
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 Anthropic SSE chunk (text_delta events). */
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(line.slice(5).trim());
39
- if (d?.type === "content_block_delta" && d.delta?.type === "text_delta") out += d.delta.text || "";
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
- function readBody(req) {
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
- req.on("data", c => { data += c; if (data.length > 25 * 1024 * 1024) req.destroy(); });
62
- req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
63
- req.on("error", reject);
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
- // Transparent passthrough for other Anthropic endpoints the client probes —
76
- // notably /v1/messages/count_tokens, which Claude Code calls before every
77
- // request and treats a 404 on as "model unavailable". Forward to the frontier.
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
- if (body && cfg.masking?.enabled !== false) {
83
- const m = createMasker(cfg.masking);
84
- body = rewriteText(body, dialect(body, req.url), s => m.mask(s).text);
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()). Send it as a normal JSON 200.
108
- if (s.prebuilt) return send(s.status || 200, s.json);
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(synthAnthropicSse(restored, model));
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(synthAnthropicSse(
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 --token sk_solrouter_…\` with a valid key from solrouter.com/sdk, then retry.`,
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
- // A proxy is already listening here — reuse it rather than crash.
186
- console.log(`SolRouter proxy already running on http://127.0.0.1:${cfg.port} — reusing it.`);
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
- // Keep a secret's shape (prefix + length class) but randomise the body, so the
78
- // model sees a plausible key and never the real one.
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
- // Keep a leading prefix so the type stays recognizable (sk-proj-, sk-ant-, AKIA…)
82
- // without leaking a short secret: proportional, capped at 8 chars.
83
- const keep = Math.min(8, Math.max(3, Math.floor(value.length * 0.3)));
84
- let seen = 0;
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 = {