solrouter 0.1.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/README.md +119 -0
- package/SPEC.md +199 -0
- package/TESTING.md +110 -0
- package/bin/proxy.js +91 -0
- package/config.example.json +24 -0
- package/package.json +36 -0
- package/src/auth.js +69 -0
- package/src/config.js +85 -0
- package/src/demo.js +71 -0
- package/src/index.js +122 -0
- package/src/mask.js +121 -0
- package/src/providers.js +100 -0
- package/src/pseudonymise.js +0 -0
- package/src/receipt.js +62 -0
- package/src/route.js +64 -0
- package/src/secrets.js +48 -0
- package/src/server.js +81 -0
- package/src/surrogates.js +130 -0
- package/src/wire.js +79 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// config.js — load proxy config and the saved auth token.
|
|
2
|
+
//
|
|
3
|
+
// Precedence (low → high): built-in defaults, ~/.solrouter/config.json,
|
|
4
|
+
// ./solrouter.config.json, environment variables. The auth token lives
|
|
5
|
+
// separately in ~/.solrouter/auth.json (written by `login`), chmod 600.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const HOME_DIR = join(homedir(), ".solrouter");
|
|
12
|
+
const AUTH_PATH = join(HOME_DIR, "auth.json");
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
port: 8787,
|
|
16
|
+
mode: "auto", // auto | frontier | private — default routing mode
|
|
17
|
+
// Where each leg goes. Both are OpenAI/Anthropic-compatible base URLs.
|
|
18
|
+
frontier: {
|
|
19
|
+
baseUrl: "https://api.anthropic.com",
|
|
20
|
+
dialect: "anthropic", // anthropic | openai
|
|
21
|
+
model: null, // null = keep the client's requested model
|
|
22
|
+
auth: "passthrough", // passthrough (default) = use the dev's OWN creds,
|
|
23
|
+
// so the proxy is a drop-in for existing setups.
|
|
24
|
+
// Set apiKey (or auth:"solrouter") to override.
|
|
25
|
+
},
|
|
26
|
+
// The uncensored leg: the SolRouter backend's /agent route (open-weight model,
|
|
27
|
+
// sk_solrouter_ key). Stateless for API-key users; prompt is pseudonymised first.
|
|
28
|
+
private: {
|
|
29
|
+
baseUrl: "https://solrouter-obb4.onrender.com",
|
|
30
|
+
transport: "solrouter-agent",
|
|
31
|
+
model: "qwen3.8:27b",
|
|
32
|
+
auth: "solrouter",
|
|
33
|
+
},
|
|
34
|
+
masking: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
names: [], // customer gazetteer
|
|
37
|
+
guessNames: true,
|
|
38
|
+
},
|
|
39
|
+
receipt: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
path: join(HOME_DIR, "receipts.jsonl"),
|
|
42
|
+
},
|
|
43
|
+
// Route each leg through the SolRouter backend (which holds provider keys and
|
|
44
|
+
// does x402 billing) instead of hitting providers directly.
|
|
45
|
+
viaSolrouter: true,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function readJson(path) {
|
|
49
|
+
try { return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {}; }
|
|
50
|
+
catch { return {}; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deepMerge(a, b) {
|
|
54
|
+
const out = { ...a };
|
|
55
|
+
for (const k of Object.keys(b || {})) {
|
|
56
|
+
out[k] = b[k] && typeof b[k] === "object" && !Array.isArray(b[k])
|
|
57
|
+
? deepMerge(a[k] || {}, b[k]) : b[k];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadConfig(cliOverrides = {}) {
|
|
63
|
+
let cfg = DEFAULTS;
|
|
64
|
+
cfg = deepMerge(cfg, readJson(join(HOME_DIR, "config.json")));
|
|
65
|
+
cfg = deepMerge(cfg, readJson(join(process.cwd(), "solrouter.config.json")));
|
|
66
|
+
if (process.env.SOLROUTER_PROXY_PORT) cfg.port = Number(process.env.SOLROUTER_PROXY_PORT);
|
|
67
|
+
// Provider keys from env if talking to providers directly.
|
|
68
|
+
cfg.frontier.apiKey ||= process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || "";
|
|
69
|
+
cfg.private.apiKey ||= process.env.SOLROUTER_API_KEY || "";
|
|
70
|
+
return deepMerge(cfg, cliOverrides);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── auth token ────────────────────────────────────────────────────────────────
|
|
74
|
+
export function saveAuth(token) {
|
|
75
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
76
|
+
writeFileSync(AUTH_PATH, JSON.stringify(token, null, 2));
|
|
77
|
+
try { chmodSync(AUTH_PATH, 0o600); } catch {}
|
|
78
|
+
}
|
|
79
|
+
export function loadAuth() { return readJson(AUTH_PATH); }
|
|
80
|
+
export function isLoggedIn() {
|
|
81
|
+
const a = loadAuth();
|
|
82
|
+
return !!(a.access_token && (!a.expires_at || a.expires_at > Date.now()));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default { loadConfig, saveAuth, loadAuth, isLoggedIn, HOME_DIR };
|
package/src/demo.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// demo.js — a self-contained proof you can run with no API key. Spins a local mock
|
|
2
|
+
// "provider" that reports back exactly what it received, runs a request with real
|
|
3
|
+
// PII through the proxy, and shows: what the provider saw (surrogates) vs what you
|
|
4
|
+
// get back (restored), plus the receipt. This is the "does it actually work" check.
|
|
5
|
+
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { handle } from "./index.js";
|
|
11
|
+
|
|
12
|
+
const SAMPLE =
|
|
13
|
+
"Draft a short email to Dr. Margaret Ellison (margaret.ellison@acme.co) about " +
|
|
14
|
+
"invoice #4021 — her IBAN DE44 5001 0517 5407 3249 31 was charged on card " +
|
|
15
|
+
"4111 1111 1111 1111. Our contact there is Klaus Baumann, +49 170 1234567. " +
|
|
16
|
+
"The prod DB is at db.internal.acme.corp and the key is sk-proj-abc123def456ghi789jkl.";
|
|
17
|
+
|
|
18
|
+
export async function runDemo() {
|
|
19
|
+
// Mock provider: capture what it received, echo a plausible answer that reuses
|
|
20
|
+
// the (masked) identifiers, so we can watch them get restored on the way out.
|
|
21
|
+
let providerSaw = "";
|
|
22
|
+
const upstream = createServer((req, res) => {
|
|
23
|
+
let b = ""; req.on("data", c => b += c); req.on("end", () => {
|
|
24
|
+
const body = JSON.parse(b || "{}");
|
|
25
|
+
providerSaw = (body.messages || []).map(m => m.content).join(" ");
|
|
26
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
27
|
+
res.end(JSON.stringify({ choices: [{ message: { role: "assistant",
|
|
28
|
+
content: "Drafted. I addressed the recipient by name and referenced their IBAN and card." } }] }));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
await new Promise(r => upstream.listen(0, "127.0.0.1", r));
|
|
32
|
+
const up = upstream.address().port;
|
|
33
|
+
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "solrouter-demo-"));
|
|
35
|
+
const cfg = {
|
|
36
|
+
mode: "auto",
|
|
37
|
+
frontier: { baseUrl: `http://127.0.0.1:${up}`, dialect: "openai", model: null, auth: "passthrough" },
|
|
38
|
+
private: { baseUrl: `http://127.0.0.1:${up}`, dialect: "openai", model: "qwen", auth: "solrouter" },
|
|
39
|
+
masking: { enabled: true, guessNames: true },
|
|
40
|
+
receipt: { enabled: true, path: join(dir, "receipts.jsonl") },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const body = { model: "your-model", messages: [{ role: "user", content: SAMPLE }] };
|
|
44
|
+
const out = await handle(body, { path: "/v1/chat/completions", headers: {} }, cfg);
|
|
45
|
+
|
|
46
|
+
const real = [
|
|
47
|
+
"Margaret Ellison", "margaret.ellison@acme.co", "Klaus Baumann",
|
|
48
|
+
"+49 170 1234567", "4111 1111 1111 1111", "db.internal.acme.corp",
|
|
49
|
+
"sk-proj-abc123def456ghi789jkl", "DE44",
|
|
50
|
+
];
|
|
51
|
+
const leaked = real.filter(v => providerSaw.includes(v));
|
|
52
|
+
|
|
53
|
+
const line = "─".repeat(72);
|
|
54
|
+
console.log("\n" + line);
|
|
55
|
+
console.log("YOU TYPED (stayed on your machine):\n");
|
|
56
|
+
console.log(" " + SAMPLE.replace(/\. /g, ".\n "));
|
|
57
|
+
console.log("\n" + line);
|
|
58
|
+
console.log("WHAT THE PROVIDER RECEIVED (surrogates only):\n");
|
|
59
|
+
console.log(" " + providerSaw.replace(/\. /g, ".\n "));
|
|
60
|
+
console.log("\n" + line);
|
|
61
|
+
console.log("REAL IDENTIFIERS LEAKED TO PROVIDER: " + (leaked.length ? leaked.join(", ") : "NONE ✓"));
|
|
62
|
+
console.log(line);
|
|
63
|
+
console.log("RECEIPT (labels + hashes only, no PII — safe to keep):\n");
|
|
64
|
+
console.log(" " + readFileSync(cfg.receipt.path, "utf8").trim());
|
|
65
|
+
console.log(line + "\n");
|
|
66
|
+
|
|
67
|
+
upstream.close();
|
|
68
|
+
return leaked.length === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default { runDemo };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// index.js — the orchestration core. One request in, one answer out:
|
|
2
|
+
// detect dialect → mask (local) → decide leg → call leg → (reactive) detect a
|
|
3
|
+
// refusal and retry on the private leg → restore locally → write a receipt.
|
|
4
|
+
//
|
|
5
|
+
// The two legs speak different backends:
|
|
6
|
+
// frontier — the dev's own provider (Anthropic/OpenAI), credentials passed
|
|
7
|
+
// through, so the proxy is a drop-in for an existing setup.
|
|
8
|
+
// private — the SolRouter backend's /agent route (uncensored open-weight model,
|
|
9
|
+
// sk_solrouter_ key). The prompt is pseudonymised before it goes.
|
|
10
|
+
//
|
|
11
|
+
// Streaming note (P0): reactive refusal-retry needs the whole answer, so streaming
|
|
12
|
+
// uses the proactive route decision only. The private leg has no streaming endpoint
|
|
13
|
+
// yet, so a streamed request that resolves to private is answered non-streamed.
|
|
14
|
+
|
|
15
|
+
import { dialect, extractText, rewriteText, extractResponseText, rewriteResponseText, buildResponse } from "./wire.js";
|
|
16
|
+
import { createMasker } from "./mask.js";
|
|
17
|
+
import { decideRoute, isRefusal } from "./route.js";
|
|
18
|
+
import { callLeg, streamLeg, callSolrouterAgent } from "./providers.js";
|
|
19
|
+
import { writeReceipt } from "./receipt.js";
|
|
20
|
+
|
|
21
|
+
/** Attach a per-request intent note to the frontier leg only (never the private one). */
|
|
22
|
+
function withIntent(body, kind, intent) {
|
|
23
|
+
if (!intent) return body;
|
|
24
|
+
const note = `Context for this request: ${intent}`;
|
|
25
|
+
const out = structuredClone(body);
|
|
26
|
+
if (kind === "anthropic") {
|
|
27
|
+
out.system = out.system ? `${note}\n\n${typeof out.system === "string" ? out.system : ""}` : note;
|
|
28
|
+
} else {
|
|
29
|
+
out.messages = [{ role: "system", content: note }, ...(out.messages || [])];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Call one leg with an already-masked body. Returns { status, json, model }. */
|
|
35
|
+
async function dispatch(cfg, which, maskedBody, kind, clientHeaders, intent) {
|
|
36
|
+
if (which === "private") {
|
|
37
|
+
const prompt = extractText(maskedBody, kind);
|
|
38
|
+
const r = await callSolrouterAgent(cfg.private, prompt);
|
|
39
|
+
return { status: r.status, ok: r.ok, model: cfg.private.model,
|
|
40
|
+
json: r.ok ? buildResponse(kind, r.reply, cfg.private.model)
|
|
41
|
+
: { error: r.error || "private_leg_error", detail: r.raw } };
|
|
42
|
+
}
|
|
43
|
+
const leg = cfg.frontier;
|
|
44
|
+
const toSend = withIntent(maskedBody, kind, intent);
|
|
45
|
+
const r = await callLeg(leg, toSend, clientHeaders);
|
|
46
|
+
return { status: r.status, ok: r.ok, json: r.json, model: leg.model, dialect: leg.dialect || kind };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Handle one non-streaming request. Returns { status, json, receipt, leg, reason, refused }. */
|
|
50
|
+
export async function handle(body, ctx = {}, cfg) {
|
|
51
|
+
const kind = dialect(body, ctx.path);
|
|
52
|
+
const forced = ctx.headers?.["x-route"];
|
|
53
|
+
const intent = ctx.headers?.["x-intent"];
|
|
54
|
+
const mode = ctx.headers?.["x-mode"] || cfg.mode;
|
|
55
|
+
|
|
56
|
+
// 1. mask — local, nothing leaves unmasked
|
|
57
|
+
const masker = createMasker(cfg.masking);
|
|
58
|
+
const masked = cfg.masking?.enabled === false
|
|
59
|
+
? body : rewriteText(body, kind, s => masker.mask(s).text);
|
|
60
|
+
|
|
61
|
+
// 2. decide leg
|
|
62
|
+
let { leg: which, reason } = decideRoute({ text: extractText(body, kind), forced, mode });
|
|
63
|
+
|
|
64
|
+
// 3. call, with reactive refusal retry: frontier refuses → private
|
|
65
|
+
let refused = false;
|
|
66
|
+
let resp = await dispatch(cfg, which, masked, kind, ctx.headers, intent);
|
|
67
|
+
if (which === "frontier" && mode === "auto" && resp.ok) {
|
|
68
|
+
const answer = extractResponseText(resp.json, resp.dialect || kind);
|
|
69
|
+
if (isRefusal(answer)) {
|
|
70
|
+
refused = true; which = "private"; reason = "reactive-refusal";
|
|
71
|
+
resp = await dispatch(cfg, "private", masked, kind, ctx.headers, intent);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. restore — local
|
|
76
|
+
const respDialect = which === "private" ? kind : (resp.dialect || kind);
|
|
77
|
+
let outJson = resp.json;
|
|
78
|
+
if (cfg.masking?.enabled !== false && resp.ok && outJson) {
|
|
79
|
+
outJson = rewriteResponseText(outJson, respDialect, s => masker.unmask(s));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 5. receipt
|
|
83
|
+
let receipt = null;
|
|
84
|
+
if (cfg.receipt?.enabled) {
|
|
85
|
+
receipt = writeReceipt(cfg.receipt.path, {
|
|
86
|
+
leg: which, reason, model: resp.model,
|
|
87
|
+
entities: masker.entities(), refused, sentToProvider: JSON.stringify(masked),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { status: resp.status, json: outJson, receipt, leg: which, reason, refused };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle one streaming request. Returns either a live { stream, masker, … } for the
|
|
95
|
+
* frontier leg, or { prebuilt: true, json } when the leg can't stream (private).
|
|
96
|
+
* Caller pipes `stream` through masker.streamingUnmasker() and receipts on end.
|
|
97
|
+
*/
|
|
98
|
+
export async function handleStream(body, ctx = {}, cfg) {
|
|
99
|
+
const kind = dialect(body, ctx.path);
|
|
100
|
+
const forced = ctx.headers?.["x-route"];
|
|
101
|
+
const intent = ctx.headers?.["x-intent"];
|
|
102
|
+
const mode = ctx.headers?.["x-mode"] || cfg.mode;
|
|
103
|
+
|
|
104
|
+
const masker = createMasker(cfg.masking);
|
|
105
|
+
const masked = cfg.masking?.enabled === false
|
|
106
|
+
? body : rewriteText(body, kind, s => masker.mask(s).text);
|
|
107
|
+
const { leg, reason } = decideRoute({ text: extractText(body, kind), forced, mode });
|
|
108
|
+
|
|
109
|
+
if (leg === "private") {
|
|
110
|
+
// No streaming endpoint on the private leg yet — answer non-streamed.
|
|
111
|
+
const out = await handle(body, ctx, cfg);
|
|
112
|
+
return { prebuilt: true, status: out.status, json: out.json, leg, reason };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const toSend = withIntent(masked, kind, intent);
|
|
116
|
+
const r = await streamLeg(cfg.frontier, toSend, ctx.headers);
|
|
117
|
+
return { status: r.status, ok: r.ok, stream: r.stream, masker, leg, reason,
|
|
118
|
+
model: cfg.frontier.model, sent: JSON.stringify(masked) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { createMasker } from "./mask.js";
|
|
122
|
+
export default { handle, handleStream };
|
package/src/mask.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// mask.js — pseudonymise a request before it leaves the machine, restore the reply
|
|
2
|
+
// as it comes back. Uses pseudonymise.js only to DETECT identifiers, then swaps in
|
|
3
|
+
// realistic type-consistent surrogates (see surrogates.js) rather than «TOKEN»
|
|
4
|
+
// placeholders, and keeps a reverse map that never leaves.
|
|
5
|
+
//
|
|
6
|
+
// A session masker is stateful on purpose: the same real value maps to the same
|
|
7
|
+
// surrogate across every call in a conversation, so the model never sees the same
|
|
8
|
+
// person under two different names.
|
|
9
|
+
|
|
10
|
+
import { pseudonymise } from "./pseudonymise.js";
|
|
11
|
+
import { SECRET_PATTERNS } from "./secrets.js";
|
|
12
|
+
import { surrogateFor } from "./surrogates.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a session-scoped masker. Hold one per conversation.
|
|
16
|
+
* @param opts.names customer gazetteer (beats heuristics)
|
|
17
|
+
* @param opts.patterns extra {label, re, check} detectors
|
|
18
|
+
*/
|
|
19
|
+
export function createMasker(opts = {}) {
|
|
20
|
+
const patterns = [...SECRET_PATTERNS, ...(opts.patterns || [])];
|
|
21
|
+
const fwd = new Map(); // original value -> surrogate
|
|
22
|
+
const rev = new Map(); // surrogate -> original value
|
|
23
|
+
const used = new Set(); // surrogate collision guard
|
|
24
|
+
const seen = []; // every entity masked this session (for the receipt)
|
|
25
|
+
|
|
26
|
+
function surrogate(label, value) {
|
|
27
|
+
if (fwd.has(value)) return fwd.get(value);
|
|
28
|
+
let s = surrogateFor(label, value);
|
|
29
|
+
// Deterministic collision (two different originals → same fake): salt until unique.
|
|
30
|
+
let salt = 0;
|
|
31
|
+
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);
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
/** Mask one string. Returns { text, entities }. Updates the session map. */
|
|
38
|
+
mask(text) {
|
|
39
|
+
const { entities } = pseudonymise(text, { names: opts.names, patterns });
|
|
40
|
+
// entities are resolved, non-overlapping, sorted by start — splice in order.
|
|
41
|
+
let out = "", cursor = 0;
|
|
42
|
+
const src = String(text ?? "");
|
|
43
|
+
for (const e of entities) {
|
|
44
|
+
out += src.slice(cursor, e.start) + surrogate(e.label, e.value);
|
|
45
|
+
cursor = e.end;
|
|
46
|
+
seen.push({ label: e.label }); // label only — never store the raw value here
|
|
47
|
+
}
|
|
48
|
+
out += src.slice(cursor);
|
|
49
|
+
return { text: out, entities };
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/** Restore a complete string: surrogates → originals, fuzzy for mangled cases. */
|
|
53
|
+
unmask(text) {
|
|
54
|
+
return restore(String(text ?? ""), rev);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/** A stateful restorer for streamed output (holds back a boundary-safe tail). */
|
|
58
|
+
streamingUnmasker() {
|
|
59
|
+
return streamingRestorer(rev);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/** The reverse map, for a receipt or for persisting a session. Never send it out. */
|
|
63
|
+
map() { return Object.fromEntries(rev); },
|
|
64
|
+
/** How many distinct identifiers masked this session. */
|
|
65
|
+
size() { return rev.size; },
|
|
66
|
+
/** Every entity masked this session (labels only), for the receipt. */
|
|
67
|
+
entities() { return seen; },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── restore ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
74
|
+
|
|
75
|
+
/** Exact then case-insensitive replace. Longest surrogates first so no prefix clobbers. */
|
|
76
|
+
function restore(text, rev) {
|
|
77
|
+
const keys = [...rev.keys()].sort((a, b) => b.length - a.length);
|
|
78
|
+
for (const s of keys) {
|
|
79
|
+
if (text.includes(s)) { text = text.split(s).join(rev.get(s)); continue; }
|
|
80
|
+
// case-insensitive fallback — models re-case surrogates
|
|
81
|
+
const re = new RegExp(escapeRe(s), "gi");
|
|
82
|
+
if (re.test(text)) text = text.replace(re, rev.get(s));
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Streaming restorer. A surrogate can be split across chunk boundaries, and once a
|
|
89
|
+
* chunk ships you can't recall it — so hold back a tail that might still be growing
|
|
90
|
+
* into a surrogate, and only emit text that is safe to restore.
|
|
91
|
+
*/
|
|
92
|
+
function streamingRestorer(rev) {
|
|
93
|
+
let buf = "";
|
|
94
|
+
const keys = [...rev.keys()];
|
|
95
|
+
// How many trailing chars of buf must be held back because they could still be
|
|
96
|
+
// the START of a surrogate that will complete in a later chunk. We hold only
|
|
97
|
+
// PROPER prefixes, so a surrogate that is already complete in buf is emitted and
|
|
98
|
+
// restored rather than stranded.
|
|
99
|
+
function holdback() {
|
|
100
|
+
let hold = 0;
|
|
101
|
+
for (const k of keys) {
|
|
102
|
+
const max = Math.min(k.length - 1, buf.length); // proper prefix only
|
|
103
|
+
for (let n = max; n > hold; n--) {
|
|
104
|
+
if (buf.slice(buf.length - n) === k.slice(0, n)) { hold = n; break; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return hold;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
push(chunk) {
|
|
111
|
+
buf += String(chunk ?? "");
|
|
112
|
+
const hold = holdback();
|
|
113
|
+
const emitRaw = buf.slice(0, buf.length - hold);
|
|
114
|
+
buf = buf.slice(buf.length - hold);
|
|
115
|
+
return restore(emitRaw, rev);
|
|
116
|
+
},
|
|
117
|
+
flush() { const out = restore(buf, rev); buf = ""; return out; },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default { createMasker };
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// providers.js — send a (already-masked) request to one leg and get the answer.
|
|
2
|
+
// Speaks both OpenAI /v1/chat/completions and Anthropic /v1/messages, streaming
|
|
3
|
+
// and non-streaming. Nothing here ever sees plaintext — masking happened upstream.
|
|
4
|
+
//
|
|
5
|
+
// Auth resolution per leg, in order:
|
|
6
|
+
// 1. leg.apiKey — an explicit key in config/env
|
|
7
|
+
// 2. pass-through — forward the CLIENT's own credentials (leg.auth
|
|
8
|
+
// !== "solrouter"). This is what makes the proxy a drop-in: it inherits the
|
|
9
|
+
// dev's existing Claude Code / Cursor / GPT setup (API key OR subscription
|
|
10
|
+
// login) and needs no key of its own for the frontier leg.
|
|
11
|
+
// 3. SolRouter account — the saved login token (leg.auth === "solrouter",
|
|
12
|
+
// used for the private/uncensored leg the dev has no credentials for).
|
|
13
|
+
|
|
14
|
+
import { loadAuth } from "./config.js";
|
|
15
|
+
|
|
16
|
+
// Client auth headers worth forwarding verbatim in pass-through mode.
|
|
17
|
+
const FORWARD = ["authorization", "x-api-key", "anthropic-version", "anthropic-beta", "openai-organization"];
|
|
18
|
+
|
|
19
|
+
function authHeaders(leg, clientHeaders = {}) {
|
|
20
|
+
const h = { "content-type": "application/json" };
|
|
21
|
+
|
|
22
|
+
// 1. explicit key
|
|
23
|
+
if (leg.apiKey) {
|
|
24
|
+
if (leg.dialect === "anthropic") { h["x-api-key"] = leg.apiKey; h["anthropic-version"] = "2023-06-01"; }
|
|
25
|
+
else h["authorization"] = `Bearer ${leg.apiKey}`;
|
|
26
|
+
return h;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3. SolRouter account (private leg, or explicitly configured)
|
|
30
|
+
if (leg.auth === "solrouter") {
|
|
31
|
+
const auth = loadAuth();
|
|
32
|
+
if (auth.access_token) h["authorization"] = `Bearer ${auth.access_token}`;
|
|
33
|
+
return h;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. pass-through (default): forward the client's own credentials
|
|
37
|
+
let forwarded = false;
|
|
38
|
+
for (const k of FORWARD) if (clientHeaders[k]) { h[k] = clientHeaders[k]; forwarded = true; }
|
|
39
|
+
if (!forwarded) {
|
|
40
|
+
// nothing to forward — fall back to the account token if we have one
|
|
41
|
+
const auth = loadAuth();
|
|
42
|
+
if (auth.access_token) h["authorization"] = `Bearer ${auth.access_token}`;
|
|
43
|
+
}
|
|
44
|
+
if (leg.dialect === "anthropic" && !h["anthropic-version"]) h["anthropic-version"] = "2023-06-01";
|
|
45
|
+
return h;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function endpoint(leg) {
|
|
49
|
+
const base = leg.baseUrl.replace(/\/$/, "");
|
|
50
|
+
return leg.dialect === "anthropic" ? `${base}/v1/messages` : `${base}/v1/chat/completions`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Force the request body onto the leg's chosen model (or leave the client's if unset). */
|
|
54
|
+
function withModel(body, leg) {
|
|
55
|
+
return leg.model ? { ...body, model: leg.model } : body;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Non-streaming call. Returns { ok, status, json }. */
|
|
59
|
+
export async function callLeg(leg, body, clientHeaders) {
|
|
60
|
+
const res = await fetch(endpoint(leg), {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: authHeaders(leg, clientHeaders),
|
|
63
|
+
body: JSON.stringify(withModel({ ...body, stream: false }, leg)),
|
|
64
|
+
});
|
|
65
|
+
let json = null;
|
|
66
|
+
try { json = await res.json(); } catch {}
|
|
67
|
+
return { ok: res.ok, status: res.status, json };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The private/uncensored leg: the SolRouter backend's /agent route. Takes a plain
|
|
72
|
+
* prompt + a sk_solrouter_ key (stateless for API-key users), returns { reply }.
|
|
73
|
+
* The prompt is already pseudonymised upstream.
|
|
74
|
+
*/
|
|
75
|
+
export async function callSolrouterAgent(leg, prompt) {
|
|
76
|
+
const auth = loadAuth();
|
|
77
|
+
const key = leg.apiKey || auth.access_token;
|
|
78
|
+
if (!key) return { ok: false, status: 401, reply: "", error: "no SolRouter API key — run `login --token sk_solrouter_...`" };
|
|
79
|
+
const base = leg.baseUrl.replace(/\/$/, "");
|
|
80
|
+
const res = await fetch(`${base}/agent`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json", "authorization": `Bearer ${key}` },
|
|
83
|
+
body: JSON.stringify({ prompt, model: leg.model || "qwen3.8:27b", useTools: false }),
|
|
84
|
+
});
|
|
85
|
+
let json = null;
|
|
86
|
+
try { json = await res.json(); } catch {}
|
|
87
|
+
return { ok: res.ok && json?.success !== false, status: res.status, reply: json?.reply ?? "", raw: json };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Streaming call. Returns { ok, status, stream } (raw SSE ReadableStream). */
|
|
91
|
+
export async function streamLeg(leg, body, clientHeaders) {
|
|
92
|
+
const res = await fetch(endpoint(leg), {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: authHeaders(leg, clientHeaders),
|
|
95
|
+
body: JSON.stringify(withModel({ ...body, stream: true }, leg)),
|
|
96
|
+
});
|
|
97
|
+
return { ok: res.ok, status: res.status, stream: res.body };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default { callLeg, streamLeg };
|
|
Binary file
|
package/src/receipt.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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.
|
|
3
|
+
// The receipt never contains raw values or the map — only labels, counts, and
|
|
4
|
+
// hashes — so the audit log itself is safe to keep and to show an auditor.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { appendFileSync, readFileSync, existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const sha256 = s => createHash("sha256").update(String(s)).digest("hex");
|
|
10
|
+
|
|
11
|
+
/** Last line's hash, so a new receipt chains onto it. */
|
|
12
|
+
function lastHash(path) {
|
|
13
|
+
if (!existsSync(path)) return "GENESIS";
|
|
14
|
+
const lines = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
|
|
15
|
+
if (!lines.length) return "GENESIS";
|
|
16
|
+
try { return JSON.parse(lines.at(-1)).hash || "GENESIS"; }
|
|
17
|
+
catch { return "GENESIS"; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Append a receipt.
|
|
22
|
+
* @param path receipt file (JSONL)
|
|
23
|
+
* @param entry { leg, reason, model, entities, sentToProvider, refused }
|
|
24
|
+
*/
|
|
25
|
+
export function writeReceipt(path, entry) {
|
|
26
|
+
const prev = lastHash(path);
|
|
27
|
+
const summary = {};
|
|
28
|
+
for (const e of entry.entities || []) summary[e.label] = (summary[e.label] || 0) + 1;
|
|
29
|
+
|
|
30
|
+
const body = {
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
leg: entry.leg, // frontier | private
|
|
33
|
+
reason: entry.reason, // why it was routed there
|
|
34
|
+
model: entry.model || null,
|
|
35
|
+
detected: summary, // { PERSON: 2, OPENAI_KEY: 1, … } — labels only
|
|
36
|
+
masked_count: (entry.entities || []).length,
|
|
37
|
+
provider_payload_sha256: entry.sentToProvider ? sha256(entry.sentToProvider) : null,
|
|
38
|
+
refused: !!entry.refused, // frontier refused and we rerouted
|
|
39
|
+
prev,
|
|
40
|
+
};
|
|
41
|
+
const hash = sha256(prev + JSON.stringify(body));
|
|
42
|
+
const line = JSON.stringify({ ...body, hash });
|
|
43
|
+
appendFileSync(path, line + "\n");
|
|
44
|
+
return { ...body, hash };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Walk the chain, confirm each line hashes onto the previous. */
|
|
48
|
+
export function verifyChain(path) {
|
|
49
|
+
if (!existsSync(path)) return { ok: true, count: 0 };
|
|
50
|
+
const lines = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
|
|
51
|
+
let prev = "GENESIS";
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const rec = JSON.parse(lines[i]);
|
|
54
|
+
const { hash, ...body } = rec;
|
|
55
|
+
if (body.prev !== prev) return { ok: false, brokenAt: i, why: "prev-mismatch" };
|
|
56
|
+
if (sha256(prev + JSON.stringify(body)) !== hash) return { ok: false, brokenAt: i, why: "hash-mismatch" };
|
|
57
|
+
prev = hash;
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, count: lines.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default { writeReceipt, verifyChain };
|
package/src/route.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// route.js — decide where a request runs.
|
|
2
|
+
//
|
|
3
|
+
// Two ways a request lands on the uncensored leg:
|
|
4
|
+
// 1. explicit — the caller sets `x-route: private`
|
|
5
|
+
// 2. auto — a frontier model would refuse it, so it goes to the uncensored leg
|
|
6
|
+
// instead of failing the user.
|
|
7
|
+
//
|
|
8
|
+
// "Would a frontier model refuse" has no cheap oracle. We use both signals:
|
|
9
|
+
// · proactive — a fast local guess, so obvious cases skip the frontier round-trip
|
|
10
|
+
// (and never expose the request to a provider that would reject it anyway).
|
|
11
|
+
// · reactive — if the frontier model is tried and actually refuses, retry on the
|
|
12
|
+
// uncensored leg. This is the accurate signal; the proactive guess only exists
|
|
13
|
+
// to avoid the wasted call and the logged refusal.
|
|
14
|
+
//
|
|
15
|
+
// The proactive guesser is deliberately crude and local. Swapping it for a small
|
|
16
|
+
// classifier model in the TEE is the obvious upgrade, and the interface here
|
|
17
|
+
// (string in, {private, reason} out) is what that model would implement.
|
|
18
|
+
|
|
19
|
+
// Phrases a frontier model tends to refuse on. This is a routing hint, not a
|
|
20
|
+
// content policy — its only job is to save a round-trip. The uncensored leg's
|
|
21
|
+
// own limits are set in config, not here.
|
|
22
|
+
const LIKELY_REFUSED = [
|
|
23
|
+
/\b(?:uncensored|no\s*guardrails|jailbreak|without\s+(?:any\s+)?restrictions?)\b/i,
|
|
24
|
+
/\bexplicit\s+(?:sexual|erotic|nsfw)\b/i,
|
|
25
|
+
/\b(?:graphic|extreme)\s+violence\b/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const REFUSAL_SIGNATURES = [
|
|
29
|
+
/\bI (?:can(?:'|no)?t|won'?t|am (?:not able|unable))\b.{0,60}\b(?:help|assist|comply|provide|create|generate|continue)\b/i,
|
|
30
|
+
/\bI'?m (?:sorry|afraid)\b.{0,40}\b(?:can(?:'|no)?t|won'?t|unable)\b/i,
|
|
31
|
+
/\b(?:against|violates?)\b.{0,30}\b(?:policy|policies|guidelines|content policy)\b/i,
|
|
32
|
+
/\bI (?:must|have to) (?:decline|refuse)\b/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Cheap local guess: would the frontier model likely refuse this? */
|
|
36
|
+
export function likelyRefused(text) {
|
|
37
|
+
const s = String(text ?? "");
|
|
38
|
+
for (const re of LIKELY_REFUSED) if (re.test(s)) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Did a model response come back as a refusal? Used for the reactive retry. */
|
|
43
|
+
export function isRefusal(text) {
|
|
44
|
+
const s = String(text ?? "").trim();
|
|
45
|
+
if (!s || s.length > 1500) return false; // real refusals are short
|
|
46
|
+
for (const re of REFUSAL_SIGNATURES) if (re.test(s)) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide the leg for an outgoing request.
|
|
52
|
+
* @returns {{ leg: 'frontier'|'private', reason: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function decideRoute({ text, forced, mode = "auto" }) {
|
|
55
|
+
if (forced === "private") return { leg: "private", reason: "forced" };
|
|
56
|
+
if (forced === "frontier") return { leg: "frontier", reason: "forced" };
|
|
57
|
+
if (mode === "private") return { leg: "private", reason: "mode=private" };
|
|
58
|
+
if (mode === "frontier") return { leg: "frontier", reason: "mode=frontier" };
|
|
59
|
+
// auto
|
|
60
|
+
if (likelyRefused(text)) return { leg: "private", reason: "predicted-refusal" };
|
|
61
|
+
return { leg: "frontier", reason: "default-frontier" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default { decideRoute, likelyRefused, isRefusal };
|