solrouter 0.5.0 → 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 +71 -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 +2 -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/README.md
CHANGED
|
@@ -19,10 +19,11 @@ Two things happen to every request, **on your machine, before anything leaves**:
|
|
|
19
19
|
Every call leaves a **receipt**: a hash-chained record of what was masked, which
|
|
20
20
|
model ran it, and a hash of exactly what the provider received. No PII in it.
|
|
21
21
|
|
|
22
|
-
> **P0 (this version).** Prose
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
22
|
+
> **P0 (this version).** Prose **and tool-call** masking (message text, tool
|
|
23
|
+
> inputs, tool results, and tool-definition descriptions), reactive refusal
|
|
24
|
+
> detection (send to frontier, detect a refusal, retry on the private leg),
|
|
25
|
+
> streaming-safe restore, receipts. Not yet: fuzzy/paraphrase restore, TEE-side
|
|
26
|
+
> classifier, task decomposition. See `SPEC.md`.
|
|
26
27
|
|
|
27
28
|
## Bring your own key — it's a drop-in, not a gateway
|
|
28
29
|
|
|
@@ -56,7 +57,8 @@ straight through: `npx solrouter code --resume`. To run the proxy on its own ins
|
|
|
56
57
|
`login` opens `solrouter.com/cli/auth`, you sign in with your SolRouter
|
|
57
58
|
subscription, and a key is minted and handed back to the CLI automatically — no
|
|
58
59
|
paste. (`login --token sk_solrouter_...` still works for CI / headless.) The private
|
|
59
|
-
leg then
|
|
60
|
+
leg then seals the pseudonymised prompt with `@solrouter/sdk` and sends only
|
|
61
|
+
ciphertext to the backend's `/tee/process` route (open-weight model `qwen3.8:27b`).
|
|
60
62
|
|
|
61
63
|
Then point any OpenAI- or Anthropic-compatible tool at it — no code change:
|
|
62
64
|
|
|
@@ -73,6 +75,21 @@ export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
|
73
75
|
# ...run your tool as usual
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
### Which endpoints it serves
|
|
79
|
+
|
|
80
|
+
It masks the two request shapes it understands: `POST /v1/chat/completions`
|
|
81
|
+
(OpenAI) and `POST /v1/messages` (Anthropic), streaming included.
|
|
82
|
+
`POST /v1/messages/count_tokens` is masked and forwarded as well, because Claude
|
|
83
|
+
Code calls it before every request.
|
|
84
|
+
|
|
85
|
+
Anything else under `/v1/` that carries a body returns **404 rather than being
|
|
86
|
+
forwarded**, so nothing can leave the machine unmasked. The one that bites in
|
|
87
|
+
practice is `POST /v1/responses`, OpenAI's Responses API: Codex and the Vercel AI
|
|
88
|
+
SDK's default `openai()` provider use it. Point those at the chat-completions path
|
|
89
|
+
instead (in the AI SDK, `openai.chat("gpt-4o")`). In `--mode private` nothing goes
|
|
90
|
+
to the frontier at all: `count_tokens` is answered locally with a rough estimate
|
|
91
|
+
and `/v1/models` lists the private model.
|
|
92
|
+
|
|
76
93
|
## Per-request control (headers)
|
|
77
94
|
|
|
78
95
|
You keep the agency — override the router per call:
|
|
@@ -88,20 +105,42 @@ You keep the agency — override the router per call:
|
|
|
88
105
|
## Commands
|
|
89
106
|
|
|
90
107
|
```
|
|
91
|
-
solrouter
|
|
92
|
-
solrouter
|
|
108
|
+
solrouter first run: setup, then launch your tool through the proxy
|
|
109
|
+
solrouter code [...claude args] run the proxy in-process AND launch Claude Code through it
|
|
110
|
+
solrouter login [--token <key>] verify account (browser), or paste a key for CI/headless
|
|
111
|
+
solrouter start [--port N] [--mode auto|frontier|private] just the proxy, no tool launch
|
|
112
|
+
solrouter setup interactive configuration
|
|
93
113
|
solrouter status config + login state
|
|
94
114
|
solrouter verify check the receipt hash-chain
|
|
115
|
+
solrouter install / uninstall macOS: route EVERY Claude Code session (see below)
|
|
95
116
|
solrouter logout
|
|
117
|
+
solrouter demo offline masking demo, no account needed
|
|
96
118
|
```
|
|
97
119
|
|
|
120
|
+
### `install` changes your machine (macOS only)
|
|
121
|
+
|
|
122
|
+
`solrouter install` is invasive on purpose — it makes the proxy the default for
|
|
123
|
+
*every* Claude Code session, so there is no "which window is routed" footgun. It:
|
|
124
|
+
|
|
125
|
+
- writes a LaunchAgent at `~/Library/LaunchAgents/com.solrouter.proxy.plist`
|
|
126
|
+
(auto-start at login, auto-restart on crash), and
|
|
127
|
+
- edits `~/.claude/settings.json`, adding `env.ANTHROPIC_BASE_URL` and
|
|
128
|
+
`env.ANTHROPIC_API_KEY` so all Claude Code traffic goes through the proxy.
|
|
129
|
+
|
|
130
|
+
Heads-up: every Claude Code session then bills to that API key, not a Max/Pro
|
|
131
|
+
subscription (required to use a custom endpoint). It marks the keys it adds, so
|
|
132
|
+
`solrouter uninstall` removes exactly those and unloads the LaunchAgent. macOS only
|
|
133
|
+
(LaunchAgent + `launchctl`); on Linux/Windows run `solrouter start` and point your
|
|
134
|
+
tools at it yourself.
|
|
135
|
+
|
|
98
136
|
## Config
|
|
99
137
|
|
|
100
138
|
Copy `config.example.json` to `~/.solrouter/config.json` or
|
|
101
139
|
`./solrouter.config.json`. Set `frontier` / `private` base URLs, models, and
|
|
102
|
-
your gazetteer of `names`.
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
your gazetteer of `names`. Provider keys come from `ANTHROPIC_API_KEY` /
|
|
141
|
+
`OPENAI_API_KEY` / `SOLROUTER_API_KEY` (or a Claude Code subscription login).
|
|
142
|
+
(A `viaSolrouter` backend-proxied billing path is planned but **not wired yet** —
|
|
143
|
+
leave it out.)
|
|
105
144
|
|
|
106
145
|
## What this will not do
|
|
107
146
|
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solrouter
|
|
3
|
+
description: Give any agent private AI through a local SolRouter proxy. It masks names, emails, keys and hostnames on this machine, and it seals prompts for SolRouter's open-weight models so only the enclave can read them. Works with any framework or tool that can set an OpenAI or Anthropic base URL.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Node >= 22
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# SolRouter proxy
|
|
9
|
+
|
|
10
|
+
A local HTTP proxy on `127.0.0.1:8787`. Your agent keeps its own code. You only
|
|
11
|
+
change its base URL. The proxy speaks the OpenAI and the Anthropic HTTP formats,
|
|
12
|
+
so any client that can set one of those base URLs can use it. Examples: the
|
|
13
|
+
OpenAI or Anthropic SDKs, the Vercel AI SDK, LangChain, Codex, Cursor, Cline.
|
|
14
|
+
|
|
15
|
+
## 1. Log in (once per machine)
|
|
16
|
+
|
|
17
|
+
Run this in the agent's shell:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx -y solrouter login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
With no terminal attached, the command prints a `solrouter.com/cli/auth` link
|
|
24
|
+
and returns at once. Show the link to the user. The user opens it, signs in to
|
|
25
|
+
their SolRouter account, and clicks Connect. SolRouter mints an API key and
|
|
26
|
+
hands it straight to the proxy on this machine. Nobody copies or pastes a key.
|
|
27
|
+
|
|
28
|
+
The link works for 10 minutes and only on the machine that ran `login`. To
|
|
29
|
+
confirm, run:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx -y solrouter status # "logged in : yes (<account>)"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Never ask the user to paste the key into chat. If a key ever appears in a chat
|
|
36
|
+
or a log, tell the user to revoke it at solrouter.com/sdk.
|
|
37
|
+
|
|
38
|
+
## 2. Start the proxy
|
|
39
|
+
|
|
40
|
+
Pick one mode. The modes do not mix.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx -y solrouter start --mode private # SolRouter models only. Needs only the login.
|
|
44
|
+
npx -y solrouter start # auto: your own frontier model first, SolRouter when it refuses
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run it in the background and wait for health:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
curl -s http://127.0.0.1:8787/health # {"ok":true,"proxy":"solrouter",...}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 3. Point the agent at it
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
57
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
new OpenAI({ baseURL: "http://127.0.0.1:8787/v1", apiKey: "solrouter" })
|
|
62
|
+
new Anthropic({ baseURL: "http://127.0.0.1:8787", apiKey: "solrouter" })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In `--mode private`, the client API key is not used. Any non-empty string
|
|
66
|
+
works, because the proxy uses the SolRouter login. In `auto` mode, pass your
|
|
67
|
+
real Anthropic or OpenAI key. The proxy forwards it to that provider.
|
|
68
|
+
|
|
69
|
+
Streaming works in both modes. To send one request to SolRouter in `auto` mode,
|
|
70
|
+
add the header `x-route: private`.
|
|
71
|
+
|
|
72
|
+
Use the chat-completions or messages path. The proxy serves
|
|
73
|
+
`POST /v1/chat/completions`, `POST /v1/messages` and `POST /v1/messages/count_tokens`.
|
|
74
|
+
Any other `/v1/` path with a body returns 404, because the masker does not
|
|
75
|
+
understand that shape and nothing leaves the machine unmasked. This includes
|
|
76
|
+
`POST /v1/responses`: if the framework defaults to OpenAI's Responses API (Codex,
|
|
77
|
+
and the Vercel AI SDK's `openai()` provider), switch it to chat completions
|
|
78
|
+
(`openai.chat("gpt-4o")`).
|
|
79
|
+
|
|
80
|
+
## What each leg protects
|
|
81
|
+
|
|
82
|
+
- **Private leg (SolRouter models, `qwen3.8:27b`):** the proxy masks identifiers,
|
|
83
|
+
then `@solrouter/sdk` encrypts the prompt on this machine. The SolRouter
|
|
84
|
+
backend relays only ciphertext to the TEE. The reply comes back encrypted and
|
|
85
|
+
is decrypted here.
|
|
86
|
+
- **Frontier leg (your Claude or GPT):** identifiers are masked and restored on
|
|
87
|
+
this machine. The provider reads the masked prompt. It cannot be encrypted,
|
|
88
|
+
because the provider must read the prompt to answer it.
|
|
89
|
+
- **Both legs:** the proxy process on this machine sees the real values.
|
|
90
|
+
- **Not yet:** the SDK trusts the TEE key that the backend serves. Attestation
|
|
91
|
+
checks of that key are not built.
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
|
|
95
|
+
- `logged in : no`: run `npx -y solrouter login` and show the user the link.
|
|
96
|
+
- `401` with `no SolRouter API key` from the private leg: the login is missing.
|
|
97
|
+
Run `login` again.
|
|
98
|
+
- `502` with a `SolRouter API error: ...` message from the private leg: SolRouter
|
|
99
|
+
refused or failed. Typical causes: a revoked key (run `login` again), no plan
|
|
100
|
+
allowance or balance left, or the TEE model is offline.
|
|
101
|
+
- `404` with `is not supported by the solrouter proxy`: the client called a path
|
|
102
|
+
the masker does not understand, almost always `/v1/responses`. Switch the client
|
|
103
|
+
to chat completions.
|
|
104
|
+
- `404` with `is not available in private mode`: in `--mode private` only the
|
|
105
|
+
chat-completions and messages paths are served, because nothing may reach the
|
|
106
|
+
frontier.
|
|
107
|
+
- `ECONNREFUSED 127.0.0.1:8787`: the proxy is not running. Start it.
|
package/SPEC.md
CHANGED
|
@@ -109,9 +109,10 @@ pseudonymised.
|
|
|
109
109
|
LIMITS block — keep it).
|
|
110
110
|
|
|
111
111
|
### 5.2 Restorer (local, streaming-safe)
|
|
112
|
-
- **
|
|
113
|
-
value (LangChain graded strategies
|
|
114
|
-
|
|
112
|
+
- **Restore ladder: exact → case-insensitive → (planned) fuzzy / n-gram** — models re-case,
|
|
113
|
+
split, or paraphrase the value (LangChain graded strategies). **Shipped in 0.5.x: exact +
|
|
114
|
+
case-insensitive.** Fuzzy/n-gram is deferred, so a paraphrased surrogate can still miss;
|
|
115
|
+
realistic surrogates survive round-trips better than tokens and keep that gap small.
|
|
115
116
|
- **Streaming:** buffer at chunk boundaries; a surrogate can be split across chunks. Restore
|
|
116
117
|
over a reassembled boundary-safe window, not per token. (qaskills.sh streaming-redaction.)
|
|
117
118
|
- Leak check: assert no residual surrogate tokens in assembled output.
|
|
@@ -168,11 +169,13 @@ openly, that the user controls.
|
|
|
168
169
|
|
|
169
170
|
## 8. PoC phases (proposed, post-approval)
|
|
170
171
|
1. **P0 — local proxy, prose only:** OpenAI/Anthropic dialects, surrogate masking, reactive
|
|
171
|
-
refusal detection (substrings), fuzzy
|
|
172
|
+
refusal detection (substrings), exact + case-insensitive restore (fuzzy deferred), receipt. Demo: Claude Code on a repo with a
|
|
172
173
|
`.env`; split screen provider-view vs user-view.
|
|
173
174
|
2. **P1 — TEE router:** move detection/prediction into the TEE; add WildGuard-class classifier;
|
|
174
175
|
`x-route` + `x-intent` headers.
|
|
175
|
-
3. **P2 — tool-call + streaming:** structural JSON masking,
|
|
176
|
+
3. **P2 — tool-call + streaming:** structural JSON masking (landed early, in 0.5.2:
|
|
177
|
+
tool inputs, tool results, tool-call arguments, tool-definition descriptions),
|
|
178
|
+
streaming-safe restore.
|
|
176
179
|
4. **P3 — decomposition:** conservative orchestrator for independent/permission segments only.
|
|
177
180
|
5. **P4 — SDK middleware:** fold the core into `@solrouter/sdk` as opt-in.
|
|
178
181
|
|
package/TESTING.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Testing the proxy (no account needed)
|
|
2
2
|
|
|
3
3
|
Everything below runs locally with **no SolRouter login**. The frontier leg uses
|
|
4
|
-
your own key; the private/uncensored leg needs a login (
|
|
5
|
-
|
|
4
|
+
your own key; the private/uncensored leg needs a SolRouter login (browser `login`,
|
|
5
|
+
or `login --token` for CI/headless — see §4).
|
|
6
6
|
|
|
7
7
|
Requires Node 22+. From `dev/backend/packages/router-proxy/`:
|
|
8
8
|
|
|
@@ -81,8 +81,8 @@ Per-request control:
|
|
|
81
81
|
|
|
82
82
|
## 4. Test the private/uncensored leg (real backend)
|
|
83
83
|
|
|
84
|
-
The private leg
|
|
85
|
-
|
|
84
|
+
The private leg seals the pseudonymised prompt with `@solrouter/sdk` and sends
|
|
85
|
+
only ciphertext to the backend's `/tee/process` route, with a `sk_solrouter_` key.
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
88
|
solrouter login --token sk_solrouter_... # your SolRouter API key
|
|
@@ -93,8 +93,9 @@ curl http://127.0.0.1:8787/v1/chat/completions \
|
|
|
93
93
|
-d '{"model":"x","messages":[{"role":"user","content":"..."}]}'
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
It
|
|
97
|
-
|
|
96
|
+
It fetches `GET {backend}/tee/public-key`, then posts `POST {backend}/tee/process`
|
|
97
|
+
with `{encryptedPrompt, model:"nosana:qwen3.8:27b"}`. The reply comes back
|
|
98
|
+
encrypted and is opened on your machine. `x-route: auto` (the default) tries
|
|
98
99
|
the frontier model first and reroutes here if it refuses.
|
|
99
100
|
|
|
100
101
|
Requires the backend + its Nosana/TEE node to be up (it's the live prod backend by
|
|
@@ -106,5 +107,5 @@ default). If the Qwen node is cold, see `scripts/NOSANA_RUNBOOK.md`.
|
|
|
106
107
|
frontier leg with your own key (§2–3).
|
|
107
108
|
- **Needs a `sk_solrouter_` key + backend up:** the private/uncensored leg (§4).
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
Both logins work: the browser `login` (loopback round-trip) and the `--token` path
|
|
111
|
+
for CI/headless. Either mints/saves the `sk_solrouter_` key the private leg uses.
|
package/bin/proxy.js
CHANGED
|
@@ -7,17 +7,51 @@
|
|
|
7
7
|
// solrouter logout
|
|
8
8
|
|
|
9
9
|
import { loadConfig, isLoggedIn, loadAuth, HOME_DIR } from "../src/config.js";
|
|
10
|
-
import { browserLogin, loginWithToken } from "../src/auth.js";
|
|
10
|
+
import { browserLogin, loginWithToken, detachedLogin, listenForLogin } from "../src/auth.js";
|
|
11
11
|
import { startServer } from "../src/server.js";
|
|
12
12
|
import { verifyChain } from "../src/receipt.js";
|
|
13
13
|
import { runDemo } from "../src/demo.js";
|
|
14
|
-
import { rmSync, existsSync } from "node:fs";
|
|
14
|
+
import { rmSync, existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
const [, , cmdRaw, ...rest] = process.argv;
|
|
18
18
|
const cmd = cmdRaw || "run";
|
|
19
19
|
const flag = name => { const i = rest.indexOf(name); return i >= 0 ? (rest[i + 1] ?? true) : undefined; };
|
|
20
20
|
|
|
21
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
22
|
+
|
|
23
|
+
// Every real subcommand. Anything else in the command slot is a typo or a stray
|
|
24
|
+
// flag and must not silently fall through to "launch Claude Code".
|
|
25
|
+
const KNOWN = new Set([
|
|
26
|
+
"login", "logout", "status", "banner", "demo", "verify",
|
|
27
|
+
"install", "uninstall", "code", "claude", "setup", "run", "start",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function usage() {
|
|
31
|
+
return `solrouter ${VERSION} — local privacy proxy for coding tools
|
|
32
|
+
|
|
33
|
+
Usage: solrouter [command] [options]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
(none) / run set up on first run, then launch your tool through the proxy
|
|
37
|
+
code [args] start the proxy and launch Claude Code through it (alias: claude)
|
|
38
|
+
start run just the proxy, no tool launch
|
|
39
|
+
setup interactive configuration
|
|
40
|
+
install install as a background service (auto-start); uninstall to remove
|
|
41
|
+
login verify your SolRouter account in the browser (login --token <key> to paste).
|
|
42
|
+
Without a terminal (an agent's shell), it prints the link and returns at once.
|
|
43
|
+
logout sign out
|
|
44
|
+
status show config + login state
|
|
45
|
+
verify check the receipt hash-chain
|
|
46
|
+
demo run the masking demo (no keys needed)
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--port <n> proxy port (default from config)
|
|
50
|
+
--mode <m> routing mode
|
|
51
|
+
-v, --version print version
|
|
52
|
+
-h, --help show this help`;
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
// Find a genuinely free port starting from `start`. Prevents the footgun where a
|
|
22
56
|
// STALE proxy already on the port gets silently reused, so old code keeps running
|
|
23
57
|
// after a version bump.
|
|
@@ -60,14 +94,47 @@ async function startProxyAndLaunch(cfg, tool, launch, passThru = []) {
|
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
async function main() {
|
|
97
|
+
// Version / help, and reject unknown commands, before any command runs — so a
|
|
98
|
+
// flag or typo never falls through to the default "launch Claude Code" path.
|
|
99
|
+
if (cmdRaw === "--version" || cmdRaw === "-v") { console.log(VERSION); return; }
|
|
100
|
+
if (cmdRaw === "--help" || cmdRaw === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
101
|
+
console.log(usage());
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (cmdRaw && !KNOWN.has(cmdRaw)) {
|
|
105
|
+
console.error(`Unknown command: ${cmdRaw}\n`);
|
|
106
|
+
console.error(usage());
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
switch (cmd) {
|
|
64
111
|
case "login": {
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
if (rest.includes("--token")) {
|
|
113
|
+
// Same check as @solrouter/sdk, so a typo fails here, not on the first private call.
|
|
114
|
+
const token = flag("--token");
|
|
115
|
+
if (typeof token !== "string" || !token.startsWith("sk_solrouter_") || token.length < 33) {
|
|
116
|
+
console.error("login --token needs a SolRouter API key: sk_solrouter_ followed by at least 20 characters.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
67
119
|
loginWithToken(token, flag("--account") || null);
|
|
68
120
|
console.log("Saved. You're logged in.");
|
|
69
121
|
return;
|
|
70
122
|
}
|
|
123
|
+
// Internal: the detached listener behind an agent login.
|
|
124
|
+
if (flag("--listen")) process.exit(await listenForLogin(Number(flag("--listen")) || 600000));
|
|
125
|
+
// No terminal: an agent runs this. Print the link now; the listener waits.
|
|
126
|
+
if (!process.stdout.isTTY) {
|
|
127
|
+
try {
|
|
128
|
+
const url = await detachedLogin();
|
|
129
|
+
console.log("Open this link and approve with your SolRouter account:");
|
|
130
|
+
console.log(" " + url);
|
|
131
|
+
console.log("The link works for 10 minutes. Then run `solrouter status` to confirm.");
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error("Login failed:", e.message);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
71
138
|
try {
|
|
72
139
|
const t = await browserLogin();
|
|
73
140
|
console.log(`Logged in${t.account ? " as " + t.account : ""}.`);
|
package/config.example.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"model": "claude-opus-5"
|
|
8
8
|
},
|
|
9
9
|
"private": {
|
|
10
|
-
"baseUrl": "https://solrouter
|
|
10
|
+
"baseUrl": "https://api.solrouter.com",
|
|
11
11
|
"dialect": "openai",
|
|
12
12
|
"model": "qwen3.8:27b"
|
|
13
13
|
},
|
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
"guessNames": true
|
|
18
18
|
},
|
|
19
19
|
"receipt": {
|
|
20
|
-
"enabled": true
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
"viaSolrouter": true
|
|
20
|
+
"enabled": true
|
|
21
|
+
}
|
|
24
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Local privacy router for coding tools: pseudonymises every request before it leaves your machine, and sends work a frontier model would refuse to an uncensored open-weight model you control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin",
|
|
14
14
|
"src",
|
|
15
15
|
"README.md",
|
|
16
|
+
"SKILL.md",
|
|
16
17
|
"SPEC.md",
|
|
17
18
|
"TESTING.md",
|
|
18
19
|
"config.example.json"
|
|
@@ -32,5 +33,8 @@
|
|
|
32
33
|
"publishConfig": {
|
|
33
34
|
"access": "public"
|
|
34
35
|
},
|
|
35
|
-
"license": "MIT"
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@solrouter/sdk": "1.2.0"
|
|
39
|
+
}
|
|
36
40
|
}
|
package/src/auth.js
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
// in the browser, the page redirects back to the loopback with a token, and the
|
|
7
7
|
// CLI saves it. A `state` nonce ties the callback to this attempt.
|
|
8
8
|
//
|
|
9
|
-
// The
|
|
10
|
-
// backend)
|
|
11
|
-
//
|
|
12
|
-
// key without a paste; it needs a small backend verify page, which is optional —
|
|
13
|
-
// the token path is complete on its own.
|
|
9
|
+
// The /cli/auth page mints a SolRouter API key (`sk_solrouter_...`, bcrypt-verified
|
|
10
|
+
// by the backend) and hands it to the loopback, so nobody pastes a key.
|
|
11
|
+
// `login --token sk_solrouter_...` saves a key directly for CI and headless hosts.
|
|
14
12
|
|
|
15
13
|
import { createServer } from "node:http";
|
|
16
14
|
import { randomBytes } from "node:crypto";
|
|
17
|
-
import { exec } from "node:child_process";
|
|
18
|
-
import {
|
|
15
|
+
import { exec, spawn } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { saveAuth, HOME_DIR } from "./config.js";
|
|
19
19
|
|
|
20
20
|
const VERIFY_BASE = process.env.SOLROUTER_VERIFY_URL || "https://solrouter.com/cli/auth";
|
|
21
21
|
|
|
@@ -60,10 +60,44 @@ export function browserLogin({ timeoutMs = 180000, open = openBrowser, save = sa
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Agent login. An agent's shell tool usually shows output only after the command
|
|
64
|
+
// exits, so a login that blocks on the browser never shows its link. Instead the
|
|
65
|
+
// loopback listener runs detached (`login --listen`), hands its verify link back
|
|
66
|
+
// through a file, and the foreground command prints the link and exits at once.
|
|
67
|
+
const LOGIN_URL_FILE = join(HOME_DIR, "login-url");
|
|
68
|
+
|
|
69
|
+
/** Start the detached listener. Resolves with the verify link to show the user. */
|
|
70
|
+
export async function detachedLogin({ bin = process.argv[1], timeoutMs = 600000, waitMs = 10000 } = {}) {
|
|
71
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
72
|
+
rmSync(LOGIN_URL_FILE, { force: true });
|
|
73
|
+
spawn(process.execPath, [bin, "login", "--listen", String(timeoutMs)], { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
74
|
+
for (const end = Date.now() + waitMs; Date.now() < end; await new Promise(r => setTimeout(r, 50))) {
|
|
75
|
+
if (existsSync(LOGIN_URL_FILE)) return readFileSync(LOGIN_URL_FILE, "utf8");
|
|
76
|
+
}
|
|
77
|
+
throw new Error("the login listener did not start");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The detached half: wait for the browser, save the minted key, then exit. */
|
|
81
|
+
export async function listenForLogin(timeoutMs) {
|
|
82
|
+
const publish = (url) => {
|
|
83
|
+
const tmp = `${LOGIN_URL_FILE}.${process.pid}`;
|
|
84
|
+
writeFileSync(tmp, url, { mode: 0o600 });
|
|
85
|
+
renameSync(tmp, LOGIN_URL_FILE); // atomic: the reader never sees a partial link
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await browserLogin({ timeoutMs, open: publish });
|
|
89
|
+
return 0;
|
|
90
|
+
} catch {
|
|
91
|
+
return 1;
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(LOGIN_URL_FILE, { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
/** Stopgap: save a pasted token directly. */
|
|
64
98
|
export function loginWithToken(token, account = null) {
|
|
65
99
|
saveAuth({ access_token: token, account, expires_at: null });
|
|
66
100
|
return { access_token: token, account };
|
|
67
101
|
}
|
|
68
102
|
|
|
69
|
-
export default { browserLogin, loginWithToken };
|
|
103
|
+
export default { browserLogin, loginWithToken, detachedLogin, listenForLogin };
|
package/src/config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// ./solrouter.config.json, environment variables. The auth token lives
|
|
5
5
|
// separately in ~/.solrouter/auth.json (written by `login`), chmod 600.
|
|
6
6
|
|
|
7
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync,
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
@@ -23,11 +23,10 @@ const DEFAULTS = {
|
|
|
23
23
|
// so the proxy is a drop-in for existing setups.
|
|
24
24
|
// Set apiKey (or auth:"solrouter") to override.
|
|
25
25
|
},
|
|
26
|
-
// The uncensored leg:
|
|
27
|
-
//
|
|
26
|
+
// The uncensored leg: SolRouter's open-weight model in the TEE, reached through
|
|
27
|
+
// @solrouter/sdk (sealed to /tee/process). Prompt is pseudonymised first.
|
|
28
28
|
private: {
|
|
29
|
-
baseUrl: "https://solrouter
|
|
30
|
-
transport: "solrouter-agent",
|
|
29
|
+
baseUrl: "https://api.solrouter.com",
|
|
31
30
|
model: "qwen3.8:27b",
|
|
32
31
|
auth: "solrouter",
|
|
33
32
|
},
|
|
@@ -73,9 +72,12 @@ export function loadConfig(cliOverrides = {}) {
|
|
|
73
72
|
|
|
74
73
|
// ── auth token ────────────────────────────────────────────────────────────────
|
|
75
74
|
export function saveAuth(token) {
|
|
76
|
-
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
76
|
+
// Write a 0600 temp file, then rename over auth.json: the key is never readable by
|
|
77
|
+
// others, and a concurrent reader (`status`, the proxy) never sees a partial file.
|
|
78
|
+
const tmp = `${AUTH_PATH}.${process.pid}`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(token, null, 2), { mode: 0o600 });
|
|
80
|
+
renameSync(tmp, AUTH_PATH);
|
|
79
81
|
}
|
|
80
82
|
export function loadAuth() { return readJson(AUTH_PATH); }
|
|
81
83
|
export function isLoggedIn() {
|
package/src/index.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// The two legs speak different backends:
|
|
6
6
|
// frontier — the dev's own provider (Anthropic/OpenAI), credentials passed
|
|
7
7
|
// through, so the proxy is a drop-in for an existing setup.
|
|
8
|
-
// private —
|
|
9
|
-
//
|
|
8
|
+
// private — SolRouter's uncensored open-weight model in the TEE, via
|
|
9
|
+
// @solrouter/sdk: pseudonymised, then encrypted on this machine.
|
|
10
10
|
//
|
|
11
11
|
// Streaming note (P0): reactive refusal-retry needs the whole answer, so streaming
|
|
12
12
|
// uses the proactive route decision only. The private leg has no streaming endpoint
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { dialect, extractText, rewriteText, extractResponseText, rewriteResponseText, buildResponse } from "./wire.js";
|
|
16
16
|
import { createMasker } from "./mask.js";
|
|
17
17
|
import { decideRoute, isRefusal } from "./route.js";
|
|
18
|
-
import { callLeg, streamLeg,
|
|
18
|
+
import { callLeg, streamLeg, callSolrouterPrivate } from "./providers.js";
|
|
19
19
|
import { writeReceipt } from "./receipt.js";
|
|
20
20
|
|
|
21
21
|
/** Attach a per-request intent note to the frontier leg only (never the private one). */
|
|
@@ -35,15 +35,19 @@ function withIntent(body, kind, intent) {
|
|
|
35
35
|
async function dispatch(cfg, which, maskedBody, kind, clientHeaders, intent) {
|
|
36
36
|
if (which === "private") {
|
|
37
37
|
const prompt = extractText(maskedBody, kind);
|
|
38
|
-
const r = await
|
|
39
|
-
|
|
38
|
+
const r = await callSolrouterPrivate(cfg.private, prompt);
|
|
39
|
+
// Hash what the private leg seals (the wire carries only its ciphertext),
|
|
40
|
+
// not the pre-mask object.
|
|
41
|
+
const sent = JSON.stringify({ prompt, model: cfg.private.model || "qwen3.8:27b" });
|
|
42
|
+
return { status: r.status, ok: r.ok, model: cfg.private.model, sent,
|
|
40
43
|
json: r.ok ? buildResponse(kind, r.reply, cfg.private.model)
|
|
41
|
-
: { error: r.error || "private_leg_error"
|
|
44
|
+
: { error: r.error || "private_leg_error" } };
|
|
42
45
|
}
|
|
43
46
|
const leg = cfg.frontier;
|
|
44
47
|
const toSend = withIntent(maskedBody, kind, intent);
|
|
45
48
|
const r = await callLeg(leg, toSend, clientHeaders);
|
|
46
|
-
|
|
49
|
+
// Hash the actual outgoing body (post-intent) so the receipt matches the wire.
|
|
50
|
+
return { status: r.status, ok: r.ok, json: r.json, model: leg.model, dialect: leg.dialect || kind, sent: JSON.stringify(toSend) };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/** Handle one non-streaming request. Returns { status, json, receipt, leg, reason, refused }. */
|
|
@@ -64,7 +68,7 @@ export async function handle(body, ctx = {}, cfg) {
|
|
|
64
68
|
// 3. call, with reactive refusal retry: frontier refuses → private
|
|
65
69
|
let refused = false;
|
|
66
70
|
let resp = await dispatch(cfg, which, masked, kind, ctx.headers, intent);
|
|
67
|
-
if (which === "frontier" && mode === "auto" && resp.ok) {
|
|
71
|
+
if (which === "frontier" && mode === "auto" && forced !== "frontier" && resp.ok) {
|
|
68
72
|
const answer = extractResponseText(resp.json, resp.dialect || kind);
|
|
69
73
|
if (isRefusal(answer)) {
|
|
70
74
|
refused = true; which = "private"; reason = "reactive-refusal";
|
|
@@ -84,7 +88,7 @@ export async function handle(body, ctx = {}, cfg) {
|
|
|
84
88
|
if (cfg.receipt?.enabled) {
|
|
85
89
|
receipt = writeReceipt(cfg.receipt.path, {
|
|
86
90
|
leg: which, reason, model: resp.model,
|
|
87
|
-
entities: masker.entities(), refused, sentToProvider:
|
|
91
|
+
entities: masker.entities(), refused, sentToProvider: resp.sent,
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
94
|
return { status: resp.status, json: outJson, receipt, leg: which, reason, refused };
|
|
@@ -115,15 +119,15 @@ export async function handleStream(body, ctx = {}, cfg) {
|
|
|
115
119
|
const toSend = withIntent(masked, kind, intent);
|
|
116
120
|
const r = await streamLeg(cfg.frontier, toSend, ctx.headers);
|
|
117
121
|
return { status: r.status, ok: r.ok, stream: r.stream, masker, leg, reason,
|
|
118
|
-
model: cfg.frontier.model, sent: JSON.stringify(
|
|
119
|
-
// context the server needs to reactively reroute a streamed refusal to private
|
|
120
|
-
|
|
122
|
+
model: cfg.frontier.model, sent: JSON.stringify(toSend),
|
|
123
|
+
// context the server needs to reactively reroute a streamed refusal to private
|
|
124
|
+
// (x-route: frontier means the caller wants the frontier's answer, refusal or not):
|
|
125
|
+
kind, mode: forced === "frontier" ? "frontier" : mode, maskedPrompt: extractText(masked, kind) };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
/** Call the private leg for a (masked) prompt. Returns { reply, ok, status }. */
|
|
124
129
|
export async function privateReply(cfg, maskedPrompt) {
|
|
125
|
-
const
|
|
126
|
-
const r = await callSolrouterAgent(cfg.private, maskedPrompt);
|
|
130
|
+
const r = await callSolrouterPrivate(cfg.private, maskedPrompt);
|
|
127
131
|
return { reply: r.reply || "", ok: r.ok, status: r.status };
|
|
128
132
|
}
|
|
129
133
|
|
package/src/install.js
CHANGED
|
@@ -85,6 +85,8 @@ function resolveBin() {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
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>." };
|
|
88
90
|
const cfg = loadConfig();
|
|
89
91
|
const key = cfg.frontier.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
90
92
|
if (!key) return { ok: false, error: "No Anthropic API key configured. Run `solrouter setup` first." };
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
52
|
-
|
|
53
|
-
for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k])
|
|
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:
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
/**
|
package/src/pseudonymise.js
CHANGED
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
38
|
-
//
|
|
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:
|
|
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 =
|
|
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
|
|
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(
|
|
39
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
req.on("
|
|
63
|
-
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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()).
|
|
108
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
186
|
-
|
|
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
|
-
//
|
|
78
|
-
//
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 = {
|
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
|
}
|