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/wire.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// wire.js — read and rewrite the two request shapes the proxy accepts:
|
|
2
|
-
// OpenAI /v1/chat/completions and Anthropic /v1/messages. We
|
|
3
|
-
//
|
|
2
|
+
// OpenAI /v1/chat/completions and Anthropic /v1/messages. We mask every field
|
|
3
|
+
// that can carry the user's data — message text, tool-call inputs, tool results,
|
|
4
|
+
// and tool-definition descriptions — and pass structural fields (names, ids,
|
|
5
|
+
// types, schema keywords) through untouched so function-calling still resolves.
|
|
4
6
|
//
|
|
5
7
|
// Kept separate from routing so the two API dialects live in one place.
|
|
6
8
|
|
|
@@ -12,7 +14,16 @@ export function dialect(body, path = "") {
|
|
|
12
14
|
return "openai"; // default; chat/completions is the common case
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/** Collect every string leaf in an arbitrary JSON value (for tool payloads). */
|
|
18
|
+
function stringsOf(v, out = []) {
|
|
19
|
+
if (typeof v === "string") out.push(v);
|
|
20
|
+
else if (Array.isArray(v)) for (const x of v) stringsOf(x, out);
|
|
21
|
+
else if (v && typeof v === "object") for (const k of Object.keys(v)) stringsOf(v[k], out);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pull the concatenated user-authored text out of a request body — including tool
|
|
26
|
+
* inputs and results, so the private leg's prompt is complete, not just message text. */
|
|
16
27
|
export function extractText(body, kind) {
|
|
17
28
|
const parts = [];
|
|
18
29
|
const pushContent = c => {
|
|
@@ -20,28 +31,116 @@ export function extractText(body, kind) {
|
|
|
20
31
|
else if (Array.isArray(c)) for (const seg of c) {
|
|
21
32
|
if (typeof seg === "string") parts.push(seg);
|
|
22
33
|
else if (seg?.type === "text" && typeof seg.text === "string") parts.push(seg.text);
|
|
34
|
+
else if (seg?.type === "tool_use" && seg.input) parts.push(...stringsOf(seg.input));
|
|
35
|
+
else if (seg?.type === "tool_result") parts.push(...stringsOf(seg.content));
|
|
36
|
+
else if (seg?.type === "document") {
|
|
37
|
+
if (typeof seg.title === "string") parts.push(seg.title);
|
|
38
|
+
if (typeof seg.context === "string") parts.push(seg.context);
|
|
39
|
+
const src = seg.source;
|
|
40
|
+
if (src?.type === "text" && typeof src.data === "string") parts.push(src.data);
|
|
41
|
+
else if (src?.type === "content") pushContent(src.content);
|
|
42
|
+
}
|
|
23
43
|
}
|
|
24
44
|
};
|
|
25
45
|
if (kind === "anthropic") {
|
|
26
46
|
if (typeof body?.system === "string") parts.push(body.system);
|
|
27
47
|
else if (Array.isArray(body?.system)) pushContent(body.system);
|
|
28
48
|
}
|
|
29
|
-
for (const m of body?.messages || [])
|
|
49
|
+
for (const m of body?.messages || []) {
|
|
50
|
+
pushContent(m?.content);
|
|
51
|
+
if (Array.isArray(m?.tool_calls)) for (const tc of m.tool_calls)
|
|
52
|
+
if (tc?.function?.arguments != null) parts.push(...stringsOf(tc.function.arguments));
|
|
53
|
+
if (m?.function_call?.arguments != null) parts.push(...stringsOf(m.function_call.arguments));
|
|
54
|
+
}
|
|
30
55
|
return parts.join("\n");
|
|
31
56
|
}
|
|
32
57
|
|
|
58
|
+
// ── deep helpers for tool-call payloads ──────────────────────────────────────
|
|
59
|
+
// Tool inputs and results carry the user's data as arbitrary JSON, not text
|
|
60
|
+
// segments — the biggest silent leak in real redactors (SPEC §5.1). Mask every
|
|
61
|
+
// string VALUE, keep object keys and non-string leaves, so structure survives.
|
|
62
|
+
function maskValues(v, fn) {
|
|
63
|
+
if (typeof v === "string") return fn(v);
|
|
64
|
+
if (Array.isArray(v)) return v.map(x => maskValues(x, fn));
|
|
65
|
+
if (v && typeof v === "object") {
|
|
66
|
+
const o = {};
|
|
67
|
+
for (const k of Object.keys(v)) o[k] = maskValues(v[k], fn);
|
|
68
|
+
return o;
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A tool_result's content is a string or an array of blocks (Anthropic).
|
|
74
|
+
function maskToolResult(content, fn) {
|
|
75
|
+
if (typeof content === "string") return fn(content);
|
|
76
|
+
if (Array.isArray(content)) return content.map(b =>
|
|
77
|
+
typeof b === "string" ? fn(b)
|
|
78
|
+
: b?.type === "text" && typeof b.text === "string" ? { ...b, text: fn(b.text) } : b);
|
|
79
|
+
return content;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Mask only free-text `description` fields in a tool definition, at any depth,
|
|
83
|
+
// leaving names, types and enum values intact so the model still calls the tool.
|
|
84
|
+
function maskDescriptions(v, fn) {
|
|
85
|
+
if (Array.isArray(v)) return v.map(x => maskDescriptions(x, fn));
|
|
86
|
+
if (v && typeof v === "object") {
|
|
87
|
+
const o = {};
|
|
88
|
+
for (const k of Object.keys(v))
|
|
89
|
+
o[k] = (k === "description" && typeof v[k] === "string") ? fn(v[k]) : maskDescriptions(v[k], fn);
|
|
90
|
+
return o;
|
|
91
|
+
}
|
|
92
|
+
return v;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// OpenAI tool_calls carry arguments as a JSON STRING. Parse, mask values, re-emit;
|
|
96
|
+
// if it isn't valid JSON, mask the raw string so nothing slips through unmasked.
|
|
97
|
+
function maskArgsJson(s, fn) {
|
|
98
|
+
if (typeof s !== "string") return s;
|
|
99
|
+
try { return JSON.stringify(maskValues(JSON.parse(s), fn)); }
|
|
100
|
+
catch { return fn(s); }
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
/** Apply a (text -> text) transform to every user-authored string in the body. */
|
|
34
104
|
export function rewriteText(body, kind, fn) {
|
|
35
105
|
const out = structuredClone(body);
|
|
36
106
|
const mapContent = c => {
|
|
37
107
|
if (typeof c === "string") return fn(c);
|
|
38
|
-
if (Array.isArray(c)) return c.map(seg =>
|
|
39
|
-
|
|
40
|
-
|
|
108
|
+
if (Array.isArray(c)) return c.map(seg => {
|
|
109
|
+
if (typeof seg === "string") return fn(seg);
|
|
110
|
+
if (seg?.type === "text" && typeof seg.text === "string") return { ...seg, text: fn(seg.text) };
|
|
111
|
+
if (seg?.type === "tool_use" && seg.input && typeof seg.input === "object") return { ...seg, input: maskValues(seg.input, fn) };
|
|
112
|
+
if (seg?.type === "tool_result") return { ...seg, content: maskToolResult(seg.content, fn) };
|
|
113
|
+
// Anthropic documents carry user prose in title, context, and the source —
|
|
114
|
+
// either source.data (type:"text") or nested text blocks (type:"content").
|
|
115
|
+
// base64/url/file sources are binary, left alone.
|
|
116
|
+
if (seg?.type === "document") {
|
|
117
|
+
const d = { ...seg };
|
|
118
|
+
if (typeof seg.title === "string") d.title = fn(seg.title);
|
|
119
|
+
if (typeof seg.context === "string") d.context = fn(seg.context);
|
|
120
|
+
const src = seg.source;
|
|
121
|
+
if (src?.type === "text" && typeof src.data === "string") d.source = { ...src, data: fn(src.data) };
|
|
122
|
+
else if (src?.type === "content" && Array.isArray(src.content)) d.source = { ...src, content: mapContent(src.content) };
|
|
123
|
+
return d;
|
|
124
|
+
}
|
|
125
|
+
return seg;
|
|
126
|
+
});
|
|
41
127
|
return c;
|
|
42
128
|
};
|
|
43
129
|
if (kind === "anthropic" && out.system != null) out.system = mapContent(out.system);
|
|
44
|
-
if (Array.isArray(out.messages)) out.messages = out.messages.map(m =>
|
|
130
|
+
if (Array.isArray(out.messages)) out.messages = out.messages.map(m => {
|
|
131
|
+
const mm = { ...m, content: mapContent(m.content) };
|
|
132
|
+
// OpenAI: an assistant turn's tool calls carry user data in .function.arguments
|
|
133
|
+
if (Array.isArray(m.tool_calls)) mm.tool_calls = m.tool_calls.map(tc =>
|
|
134
|
+
tc?.function?.arguments != null
|
|
135
|
+
? { ...tc, function: { ...tc.function, arguments: maskArgsJson(tc.function.arguments, fn) } }
|
|
136
|
+
: tc);
|
|
137
|
+
// Legacy OpenAI: the deprecated single function_call is still honored upstream.
|
|
138
|
+
if (m.function_call?.arguments != null)
|
|
139
|
+
mm.function_call = { ...m.function_call, arguments: maskArgsJson(m.function_call.arguments, fn) };
|
|
140
|
+
return mm;
|
|
141
|
+
});
|
|
142
|
+
// Tool DEFINITIONS (both dialects) — mask their prose descriptions.
|
|
143
|
+
if (Array.isArray(out.tools)) out.tools = out.tools.map(t => maskDescriptions(t, fn));
|
|
45
144
|
return out;
|
|
46
145
|
}
|
|
47
146
|
|
|
@@ -53,14 +152,26 @@ export function extractResponseText(body, kind) {
|
|
|
53
152
|
return (body?.choices || []).map(c => c?.message?.content || "").join("");
|
|
54
153
|
}
|
|
55
154
|
|
|
56
|
-
/** Rewrite the assistant's text in a (non-streaming) response body.
|
|
155
|
+
/** Rewrite the assistant's text in a (non-streaming) response body. Restores tool
|
|
156
|
+
* outputs too — the model echoes surrogates into tool_use/tool_calls, and the client
|
|
157
|
+
* would otherwise EXECUTE the tool on fake values. Symmetric with rewriteText's masking. */
|
|
57
158
|
export function rewriteResponseText(body, kind, fn) {
|
|
58
159
|
const out = structuredClone(body);
|
|
59
160
|
if (kind === "anthropic") {
|
|
60
|
-
out.content = (out.content || []).map(b =>
|
|
161
|
+
out.content = (out.content || []).map(b =>
|
|
162
|
+
b?.type === "text" ? { ...b, text: fn(b.text) }
|
|
163
|
+
: b?.type === "tool_use" && b.input && typeof b.input === "object" ? { ...b, input: maskValues(b.input, fn) }
|
|
164
|
+
: b);
|
|
61
165
|
} else {
|
|
62
|
-
out.choices = (out.choices || []).map(c =>
|
|
63
|
-
|
|
166
|
+
out.choices = (out.choices || []).map(c => {
|
|
167
|
+
if (!c?.message) return c;
|
|
168
|
+
const message = { ...c.message, content: fn(c.message.content || "") };
|
|
169
|
+
if (Array.isArray(c.message.tool_calls)) message.tool_calls = c.message.tool_calls.map(tc =>
|
|
170
|
+
tc?.function?.arguments != null
|
|
171
|
+
? { ...tc, function: { ...tc.function, arguments: maskArgsJson(tc.function.arguments, fn) } }
|
|
172
|
+
: tc);
|
|
173
|
+
return { ...c, message };
|
|
174
|
+
});
|
|
64
175
|
}
|
|
65
176
|
return out;
|
|
66
177
|
}
|