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/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 only touch the
3
- // text the user wrote; system prompts, roles and every other field pass through.
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
- /** Pull the concatenated user-authored text out of a request body. */
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 || []) pushContent(m?.content);
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
- seg?.type === "text" && typeof seg.text === "string" ? { ...seg, text: fn(seg.text) }
40
- : typeof seg === "string" ? fn(seg) : seg);
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 => ({ ...m, content: mapContent(m.content) }));
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 => b?.type === "text" ? { ...b, text: fn(b.text) } : 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
- c?.message ? { ...c, message: { ...c.message, content: fn(c.message.content || "") } } : c);
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
  }