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/secrets.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// secrets.js — detectors for the things a coding agent leaks that pseudonymise.js
|
|
2
|
+
// does not look for: API keys, tokens, private keys, connection strings, internal
|
|
3
|
+
// hosts. Same shape as pseudonymise's `patterns` option, so they compose: pass
|
|
4
|
+
// SECRET_PATTERNS through and they share one overlap-resolution pass.
|
|
5
|
+
//
|
|
6
|
+
// Zero-dependency by design. This file ships into someone else's machine.
|
|
7
|
+
|
|
8
|
+
export const SECRET_PATTERNS = [
|
|
9
|
+
// Provider keys — specific prefixes first so the generic rule never eats them.
|
|
10
|
+
{ label: "OPENAI_KEY", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/ },
|
|
11
|
+
{ label: "ANTHROPIC_KEY", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
12
|
+
{ label: "SOLROUTER_KEY", re: /\bsk_solrouter_[A-Za-z0-9]{16,}\b/ },
|
|
13
|
+
{ label: "AWS_KEY", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
|
|
14
|
+
{ label: "GITHUB_TOKEN", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
|
|
15
|
+
{ label: "GOOGLE_KEY", re: /\bAIza[A-Za-z0-9_-]{35}\b/ },
|
|
16
|
+
{ label: "SLACK_TOKEN", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
17
|
+
{ label: "STRIPE_KEY", re: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
|
18
|
+
|
|
19
|
+
// PEM private keys — grab the whole block, not just the header.
|
|
20
|
+
{ label: "PRIVATE_KEY",
|
|
21
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----/ },
|
|
22
|
+
|
|
23
|
+
// JWTs — three base64url segments.
|
|
24
|
+
{ label: "JWT", re: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/ },
|
|
25
|
+
|
|
26
|
+
// Connection strings with an embedded password.
|
|
27
|
+
{ label: "DB_URL",
|
|
28
|
+
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/]+:[^\s@/]+@[^\s/]+/i },
|
|
29
|
+
|
|
30
|
+
// Bearer header values.
|
|
31
|
+
{ label: "BEARER", re: /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*\b/ },
|
|
32
|
+
|
|
33
|
+
// Internal hostnames a model has no business seeing.
|
|
34
|
+
{ label: "INTERNAL_HOST",
|
|
35
|
+
re: /\b[a-z0-9][a-z0-9.-]*\.(?:internal|corp|local|intranet|lan|svc\.cluster\.local)\b/i },
|
|
36
|
+
|
|
37
|
+
// "SECRET=…", "api_key: …", "token=…" — a labelled assignment. Captures the
|
|
38
|
+
// value; validated to skip obvious placeholders like "your-key-here".
|
|
39
|
+
{ 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,
|
|
41
|
+
check: v => {
|
|
42
|
+
const val = (v.split(/[:=]/).slice(1).join("=") || v).replace(/["']/g, "").trim();
|
|
43
|
+
return !/^(?:your|my|the|example|changeme|placeholder|xxx+|<[^>]+>|\.\.\.)/i.test(val)
|
|
44
|
+
&& !/^\$\{?[A-Z0-9_]+\}?$/.test(val); // not itself an env-var reference
|
|
45
|
+
} },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export default SECRET_PATTERNS;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// server.js — the local HTTP endpoint a coding tool points at. Exposes the two
|
|
2
|
+
// dialects a coding tool speaks:
|
|
3
|
+
// POST /v1/chat/completions (OpenAI)
|
|
4
|
+
// POST /v1/messages (Anthropic)
|
|
5
|
+
// GET /health
|
|
6
|
+
// It masks, routes, restores, and receipts each call via index.js. Binds to
|
|
7
|
+
// localhost only — nothing here should be reachable off the machine.
|
|
8
|
+
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { handle, handleStream } from "./index.js";
|
|
11
|
+
import { writeReceipt } from "./receipt.js";
|
|
12
|
+
|
|
13
|
+
function readBody(req) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let data = "";
|
|
16
|
+
req.on("data", c => { data += c; if (data.length > 25 * 1024 * 1024) req.destroy(); });
|
|
17
|
+
req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
|
|
18
|
+
req.on("error", reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lower = h => Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]));
|
|
23
|
+
|
|
24
|
+
export function startServer(cfg) {
|
|
25
|
+
const server = createServer(async (req, res) => {
|
|
26
|
+
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
27
|
+
|
|
28
|
+
if (req.method === "GET" && req.url === "/health") return send(200, { ok: true, proxy: "solrouter", mode: cfg.mode });
|
|
29
|
+
if (req.method !== "POST" || !/^\/(v1\/chat\/completions|v1\/messages)$/.test(req.url)) {
|
|
30
|
+
return send(404, { error: "not found" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let body;
|
|
34
|
+
try { body = await readBody(req); } catch { return send(400, { error: "invalid JSON body" }); }
|
|
35
|
+
const ctx = { path: req.url, headers: lower(req.headers) };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (body.stream) {
|
|
39
|
+
const s = await handleStream(body, ctx, cfg);
|
|
40
|
+
// Private leg can't stream yet — it returns a prebuilt body (receipt
|
|
41
|
+
// already written inside handle()). Send it as a normal JSON 200.
|
|
42
|
+
if (s.prebuilt) return send(s.status || 200, s.json);
|
|
43
|
+
if (!s.ok || !s.stream) return send(s.status || 502, { error: "upstream error", leg: s.leg });
|
|
44
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
45
|
+
const restorer = cfg.masking?.enabled === false ? null : s.masker.streamingUnmasker();
|
|
46
|
+
const reader = s.stream.getReader();
|
|
47
|
+
const dec = new TextDecoder();
|
|
48
|
+
for (;;) {
|
|
49
|
+
const { done, value } = await reader.read();
|
|
50
|
+
if (done) break;
|
|
51
|
+
const chunk = dec.decode(value, { stream: true });
|
|
52
|
+
res.write(restorer ? restorer.push(chunk) : chunk);
|
|
53
|
+
}
|
|
54
|
+
if (restorer) res.write(restorer.flush());
|
|
55
|
+
res.end();
|
|
56
|
+
if (cfg.receipt?.enabled) writeReceipt(cfg.receipt.path, {
|
|
57
|
+
leg: s.leg, reason: s.reason, model: s.model,
|
|
58
|
+
entities: s.masker.entities(), refused: false, sentToProvider: s.sent,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const out = await handle(body, ctx, cfg);
|
|
64
|
+
res.writeHead(out.status || 200, { "content-type": "application/json" });
|
|
65
|
+
res.end(JSON.stringify(out.json ?? { error: "no response" }));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
send(502, { error: "proxy error", detail: String(e?.message || e) });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
server.listen(cfg.port, "127.0.0.1", () => {
|
|
72
|
+
console.log(`SolRouter proxy on http://127.0.0.1:${cfg.port}`);
|
|
73
|
+
console.log(` mode=${cfg.mode} frontier=${cfg.frontier.model || "(client's model)"} [${cfg.frontier.auth || "passthrough"}] private=${cfg.private.model}`);
|
|
74
|
+
console.log(` point your tool's base URL here:`);
|
|
75
|
+
console.log(` OpenAI-style: http://127.0.0.1:${cfg.port}/v1`);
|
|
76
|
+
console.log(` Anthropic-style: http://127.0.0.1:${cfg.port}`);
|
|
77
|
+
});
|
|
78
|
+
return server;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default { startServer };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// surrogates.js — turn a detected identifier into a realistic, type-consistent fake
|
|
2
|
+
// value, deterministically. "Marcus Bishop", not «PERSON_1».
|
|
3
|
+
//
|
|
4
|
+
// Why realistic and not a token: placeholder tokens make the model reason worse —
|
|
5
|
+
// it loses the anchors it thinks with — and it mangles them on the way back
|
|
6
|
+
// («PERSON_1» → "Person 1"). Type-consistent surrogates keep the model coherent
|
|
7
|
+
// and survive the round-trip because the model tracks them by reference.
|
|
8
|
+
// (SurrogateShield arXiv:2606.29567: +13 BERTScore over placeholders.)
|
|
9
|
+
//
|
|
10
|
+
// Deterministic: the same original value always yields the same surrogate, so a
|
|
11
|
+
// value stays consistent across a whole conversation without needing to remember
|
|
12
|
+
// every one — and re-identification is a plain reverse lookup.
|
|
13
|
+
//
|
|
14
|
+
// Zero dependency, pure JS, no node builtins — this ships into the user's machine
|
|
15
|
+
// and must run identically in Node and a browser.
|
|
16
|
+
|
|
17
|
+
// ── a tiny seeded PRNG so a value hashes to a stable surrogate ────────────────
|
|
18
|
+
function seedFrom(str) {
|
|
19
|
+
// xmur3
|
|
20
|
+
let h = 1779033703 ^ str.length;
|
|
21
|
+
for (let i = 0; i < str.length; i++) {
|
|
22
|
+
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
|
23
|
+
h = (h << 13) | (h >>> 19);
|
|
24
|
+
}
|
|
25
|
+
return () => {
|
|
26
|
+
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
|
27
|
+
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
|
28
|
+
h ^= h >>> 16;
|
|
29
|
+
return h >>> 0;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function rngFor(value) {
|
|
33
|
+
const next = seedFrom(value);
|
|
34
|
+
return {
|
|
35
|
+
int: n => next() % n,
|
|
36
|
+
pick: arr => arr[next() % arr.length],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FIRST = "James Mary Robert Patricia John Jennifer Michael Linda David Susan Marcus Elena Omar Priya Sofia Lukas Anna Tomas Nadia Felix Clara Ivan Rosa Karl Maya Leon Greta Nils Aisha Diego".split(" ");
|
|
41
|
+
const LAST = "Bishop Carver Delgado Fenn Harlow Iverson Keller Mercer Novak Rhodes Stroud Vance Whitlock Ashby Brenner Cato Dane Ellison Frost Garza Holt Krause Lindqvist Moreau Petrov Quinn Sato Vogel".split(" ");
|
|
42
|
+
const ORG_HEAD = "Meridian Northgate Alderman Brightwater Crossley Halcyon Ironwood Kestrel Lumen Oberon Solstice Vantage Westford".split(" ");
|
|
43
|
+
const ORG_TAIL = "Systems Partners Holdings Labs Group Industries Analytics Ventures Solutions Dynamics".split(" ");
|
|
44
|
+
const STREET = "Kingfisher Maple Sycamore Belmont Ashford Cranmer Downing Everett Faraday Granville Hawthorn".split(" ");
|
|
45
|
+
const CITY_DE = "Bergstraße Lindenweg Rosenplatz Marktgasse Domallee Uferring Parkdamm".split(" ");
|
|
46
|
+
|
|
47
|
+
const pad = (n, w) => String(n).padStart(w, "0");
|
|
48
|
+
|
|
49
|
+
// Build an IBAN with a valid mod-97 check digit, so the fake passes the same
|
|
50
|
+
// validator that caught the real one and downstream code stays happy.
|
|
51
|
+
function fakeIban(r) {
|
|
52
|
+
const country = r.pick(["DE", "FR", "NL", "ES", "GB"]);
|
|
53
|
+
const bban = Array.from({ length: 18 }, () => r.int(10)).join("");
|
|
54
|
+
const rearranged = bban + country + "00";
|
|
55
|
+
let rem = 0;
|
|
56
|
+
for (const ch of rearranged) {
|
|
57
|
+
const v = /\d/.test(ch) ? ch : String(ch.charCodeAt(0) - 55);
|
|
58
|
+
for (const d of v) rem = (rem * 10 + Number(d)) % 97;
|
|
59
|
+
}
|
|
60
|
+
const check = pad(98 - rem, 2);
|
|
61
|
+
return country + check + bban;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Luhn-valid fake card.
|
|
65
|
+
function fakeCard(r) {
|
|
66
|
+
const body = Array.from({ length: 15 }, () => r.int(10));
|
|
67
|
+
let sum = 0, dbl = true;
|
|
68
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
69
|
+
let d = body[i];
|
|
70
|
+
if (dbl) { d *= 2; if (d > 9) d -= 9; }
|
|
71
|
+
sum += d; dbl = !dbl;
|
|
72
|
+
}
|
|
73
|
+
const check = (10 - (sum % 10)) % 10;
|
|
74
|
+
return [...body, check].join("").replace(/(\d{4})(?=\d)/g, "$1 ");
|
|
75
|
+
}
|
|
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.
|
|
79
|
+
function fakeLikeShape(value, r) {
|
|
80
|
+
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
|
+
}
|
|
88
|
+
|
|
89
|
+
const GENERATORS = {
|
|
90
|
+
PERSON: r => `${r.pick(FIRST)} ${r.pick(LAST)}`,
|
|
91
|
+
ORG: r => `${r.pick(ORG_HEAD)} ${r.pick(ORG_TAIL)}`,
|
|
92
|
+
EMAIL: r => `${r.pick(FIRST).toLowerCase()}.${r.pick(LAST).toLowerCase()}@${r.pick(["example.com", "mail.test", "acme.co"])}`,
|
|
93
|
+
PHONE: r => `+${r.int(89) + 10} ${r.int(900) + 100} ${pad(r.int(1e6), 6)}`,
|
|
94
|
+
IBAN: r => fakeIban(r),
|
|
95
|
+
CARD: r => fakeCard(r),
|
|
96
|
+
IP: r => `${r.int(223) + 1}.${r.int(256)}.${r.int(256)}.${r.int(254) + 1}`,
|
|
97
|
+
DOB: r => `${pad(r.int(28) + 1, 2)}.${pad(r.int(12) + 1, 2)}.${1950 + r.int(55)}`,
|
|
98
|
+
DATE: r => `${pad(r.int(28) + 1, 2)}.${pad(r.int(12) + 1, 2)}.${2018 + r.int(8)}`,
|
|
99
|
+
ADDRESS: (r, v) => /stra|weg|platz|gasse|ring|damm|allee/i.test(v)
|
|
100
|
+
? `${r.pick(CITY_DE)} ${r.int(120) + 1}`
|
|
101
|
+
: `${r.int(200) + 1} ${r.pick(STREET)} Street`,
|
|
102
|
+
TAXID: r => `${r.int(90) + 10}/${pad(r.int(1000), 3)}/${pad(r.int(1e5), 5)}`,
|
|
103
|
+
VATID: r => `DE${pad(r.int(1e9), 9)}`,
|
|
104
|
+
CASEREF: r => `${r.int(90) + 10} O ${pad(r.int(1000), 3)}/${20 + r.int(6)}`,
|
|
105
|
+
MRN: r => `MRN ${pad(r.int(1e8), 8)}`,
|
|
106
|
+
INTERNAL_HOST: r => `${r.pick(["db", "api", "cache", "auth", "vault", "gw", "svc"])}.${r.pick(ORG_HEAD).toLowerCase()}.internal`,
|
|
107
|
+
DB_URL: r => `postgresql://svc:${fakeLikeShape("xxxxxxxxxxxxxxxx", r)}@${r.pick(["db", "pg"])}.${r.pick(ORG_HEAD).toLowerCase()}.internal:5432/app`,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Secret labels all keep their shape; anything unknown falls back to a shaped fake.
|
|
111
|
+
const SHAPE_LABELS = new Set([
|
|
112
|
+
"OPENAI_KEY", "ANTHROPIC_KEY", "SOLROUTER_KEY", "AWS_KEY", "GITHUB_TOKEN",
|
|
113
|
+
"GOOGLE_KEY", "SLACK_TOKEN", "STRIPE_KEY", "JWT", "BEARER", "PRIVATE_KEY",
|
|
114
|
+
"DB_URL", "INTERNAL_HOST", "ASSIGNED_SECRET",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deterministic surrogate for one detected value.
|
|
119
|
+
* @param label entity label (PERSON, IBAN, OPENAI_KEY, …)
|
|
120
|
+
* @param value the real value
|
|
121
|
+
* @returns a realistic, type-consistent fake
|
|
122
|
+
*/
|
|
123
|
+
export function surrogateFor(label, value) {
|
|
124
|
+
const r = rngFor(label + ":" + value);
|
|
125
|
+
if (GENERATORS[label]) return GENERATORS[label](r, value);
|
|
126
|
+
if (SHAPE_LABELS.has(label)) return fakeLikeShape(value, r);
|
|
127
|
+
return fakeLikeShape(value, r); // safe default: shape-preserving
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default { surrogateFor };
|
package/src/wire.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// wire.js — read and rewrite the two request shapes the proxy accepts:
|
|
2
|
+
// OpenAI /v1/chat/completions and Anthropic /v1/messages. We only touch the
|
|
3
|
+
// text the user wrote; system prompts, roles and every other field pass through.
|
|
4
|
+
//
|
|
5
|
+
// Kept separate from routing so the two API dialects live in one place.
|
|
6
|
+
|
|
7
|
+
/** Detect which dialect a request body is. */
|
|
8
|
+
export function dialect(body, path = "") {
|
|
9
|
+
if (/\/messages\b/.test(path) || Array.isArray(body?.messages) && body?.max_tokens && body?.model && !("choices" in body) && body?.system !== undefined)
|
|
10
|
+
return "anthropic";
|
|
11
|
+
if (/messages/.test(path) && body?.anthropic_version) return "anthropic";
|
|
12
|
+
return "openai"; // default; chat/completions is the common case
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Pull the concatenated user-authored text out of a request body. */
|
|
16
|
+
export function extractText(body, kind) {
|
|
17
|
+
const parts = [];
|
|
18
|
+
const pushContent = c => {
|
|
19
|
+
if (typeof c === "string") parts.push(c);
|
|
20
|
+
else if (Array.isArray(c)) for (const seg of c) {
|
|
21
|
+
if (typeof seg === "string") parts.push(seg);
|
|
22
|
+
else if (seg?.type === "text" && typeof seg.text === "string") parts.push(seg.text);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
if (kind === "anthropic") {
|
|
26
|
+
if (typeof body?.system === "string") parts.push(body.system);
|
|
27
|
+
else if (Array.isArray(body?.system)) pushContent(body.system);
|
|
28
|
+
}
|
|
29
|
+
for (const m of body?.messages || []) pushContent(m?.content);
|
|
30
|
+
return parts.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Apply a (text -> text) transform to every user-authored string in the body. */
|
|
34
|
+
export function rewriteText(body, kind, fn) {
|
|
35
|
+
const out = structuredClone(body);
|
|
36
|
+
const mapContent = c => {
|
|
37
|
+
if (typeof c === "string") return fn(c);
|
|
38
|
+
if (Array.isArray(c)) return c.map(seg =>
|
|
39
|
+
seg?.type === "text" && typeof seg.text === "string" ? { ...seg, text: fn(seg.text) }
|
|
40
|
+
: typeof seg === "string" ? fn(seg) : seg);
|
|
41
|
+
return c;
|
|
42
|
+
};
|
|
43
|
+
if (kind === "anthropic" && out.system != null) out.system = mapContent(out.system);
|
|
44
|
+
if (Array.isArray(out.messages)) out.messages = out.messages.map(m => ({ ...m, content: mapContent(m.content) }));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pull the assistant's text out of a (non-streaming) response body. */
|
|
49
|
+
export function extractResponseText(body, kind) {
|
|
50
|
+
if (kind === "anthropic") {
|
|
51
|
+
return (body?.content || []).filter(b => b?.type === "text").map(b => b.text).join("");
|
|
52
|
+
}
|
|
53
|
+
return (body?.choices || []).map(c => c?.message?.content || "").join("");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Rewrite the assistant's text in a (non-streaming) response body. */
|
|
57
|
+
export function rewriteResponseText(body, kind, fn) {
|
|
58
|
+
const out = structuredClone(body);
|
|
59
|
+
if (kind === "anthropic") {
|
|
60
|
+
out.content = (out.content || []).map(b => b?.type === "text" ? { ...b, text: fn(b.text) } : b);
|
|
61
|
+
} else {
|
|
62
|
+
out.choices = (out.choices || []).map(c =>
|
|
63
|
+
c?.message ? { ...c, message: { ...c.message, content: fn(c.message.content || "") } } : c);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a complete response body in the given dialect from a plain answer string. */
|
|
69
|
+
export function buildResponse(kind, text, model) {
|
|
70
|
+
if (kind === "anthropic") {
|
|
71
|
+
return { id: "msg_proxy", type: "message", role: "assistant",
|
|
72
|
+
content: [{ type: "text", text }], model: model || "solrouter",
|
|
73
|
+
stop_reason: "end_turn", usage: {} };
|
|
74
|
+
}
|
|
75
|
+
return { id: "chatcmpl-proxy", object: "chat.completion", model: model || "solrouter",
|
|
76
|
+
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default { dialect, extractText, rewriteText, extractResponseText, rewriteResponseText, buildResponse };
|