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/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# solrouter
|
|
2
|
+
|
|
3
|
+
A local routing proxy for your coding tools. It gives you the strongest model that
|
|
4
|
+
*will* do your work, without handing that model anything it doesn't need to know
|
|
5
|
+
about who the work is for.
|
|
6
|
+
|
|
7
|
+
Two things happen to every request, **on your machine, before anything leaves**:
|
|
8
|
+
|
|
9
|
+
1. **Pseudonymise.** Names, emails, IBANs, cards, API keys, private keys, internal
|
|
10
|
+
hostnames, case references are swapped for *realistic* stand-ins (not `[REDACTED]`
|
|
11
|
+
— that makes the model dumber). The mapping table never leaves your machine. The
|
|
12
|
+
provider does the thinking on a version of your problem with the identities
|
|
13
|
+
changed; you get the answer back with the real values restored, locally.
|
|
14
|
+
2. **Route.** Most requests go to a frontier model. Work a frontier model **won't**
|
|
15
|
+
do doesn't dead-end at a refusal — it routes to an open-weight model you control.
|
|
16
|
+
Not by tricking the frontier model or disguising the request (that doesn't work
|
|
17
|
+
and gets your account banned) — by sending it, openly, to a different model.
|
|
18
|
+
|
|
19
|
+
Every call leaves a **receipt**: a hash-chained record of what was masked, which
|
|
20
|
+
model ran it, and a hash of exactly what the provider received. No PII in it.
|
|
21
|
+
|
|
22
|
+
> **P0 (this version).** Prose masking, reactive refusal detection (send to
|
|
23
|
+
> frontier, detect a refusal, retry on the private leg), fuzzy + streaming-safe
|
|
24
|
+
> restore, receipts. Not yet: tool-call-argument masking, TEE-side classifier,
|
|
25
|
+
> task decomposition. See `SPEC.md`.
|
|
26
|
+
|
|
27
|
+
## Bring your own key — it's a drop-in, not a gateway
|
|
28
|
+
|
|
29
|
+
By default the **frontier leg uses your own credentials**, passed straight through.
|
|
30
|
+
The proxy inherits whatever your tool already uses — an `ANTHROPIC_API_KEY`, an
|
|
31
|
+
`OPENAI_API_KEY`, or a Claude Code subscription login — and needs no key of its own.
|
|
32
|
+
It's not reselling you frontier tokens; it sits in the middle, masks and routes, and
|
|
33
|
+
forwards your request under your own account.
|
|
34
|
+
|
|
35
|
+
A SolRouter account is only needed for the **private/uncensored leg** (the model you
|
|
36
|
+
don't otherwise have access to) — so you can try masking + routing with your existing
|
|
37
|
+
setup and no signup.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
frontier leg → your own key / subscription (auth: "passthrough", the default)
|
|
41
|
+
private leg → SolRouter uncensored model (auth: "solrouter", needs login)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Install & run
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx solrouter # or: npm i -g solrouter
|
|
48
|
+
solrouter start # runs on http://127.0.0.1:8787 — uses your existing key
|
|
49
|
+
solrouter login # opens the browser — sign in with your subscription
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`login` opens `solrouter.com/cli/auth`, you sign in with your SolRouter
|
|
53
|
+
subscription, and a key is minted and handed back to the CLI automatically — no
|
|
54
|
+
paste. (`login --token sk_solrouter_...` still works for CI / headless.) The private
|
|
55
|
+
leg then calls the backend's `/agent` route (open-weight model `qwen3.8:27b`).
|
|
56
|
+
|
|
57
|
+
Then point any OpenAI- or Anthropic-compatible tool at it — no code change:
|
|
58
|
+
|
|
59
|
+
| Tool | Setting |
|
|
60
|
+
|---|---|
|
|
61
|
+
| **OpenAI SDK / most tools** | base URL → `http://127.0.0.1:8787/v1` |
|
|
62
|
+
| **Anthropic SDK / Claude Code** | base URL → `http://127.0.0.1:8787` |
|
|
63
|
+
| **Cursor / Cline / Continue (VS Code)** | set the custom OpenAI base URL to `http://127.0.0.1:8787/v1` |
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
69
|
+
# ...run your tool as usual
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Per-request control (headers)
|
|
73
|
+
|
|
74
|
+
You keep the agency — override the router per call:
|
|
75
|
+
|
|
76
|
+
| Header | Effect |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `x-route: frontier` | force the frontier model |
|
|
79
|
+
| `x-route: private` | force the open model you control |
|
|
80
|
+
| `x-route: auto` (default) | frontier, falling back to the open model on a refusal |
|
|
81
|
+
| `x-mode: private` | private leg for everything this request |
|
|
82
|
+
| `x-intent: <text>` | attach a one-line intent note **to the frontier leg only** — for legitimately-scoped work (e.g. an authorized pentest) that a frontier model will do once it has context. Trades some privacy for the stronger model; never sent to the private leg. |
|
|
83
|
+
|
|
84
|
+
## Commands
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
solrouter login [--token <key>] verify account (browser), or paste a key
|
|
88
|
+
solrouter start [--port N] [--mode auto|frontier|private]
|
|
89
|
+
solrouter status config + login state
|
|
90
|
+
solrouter verify check the receipt hash-chain
|
|
91
|
+
solrouter logout
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Config
|
|
95
|
+
|
|
96
|
+
Copy `config.example.json` to `~/.solrouter/config.json` or
|
|
97
|
+
`./solrouter.config.json`. Set `frontier` / `private` base URLs, models, and
|
|
98
|
+
your gazetteer of `names`. With `viaSolrouter: true` the legs go through the
|
|
99
|
+
SolRouter backend (which holds provider keys and does x402 billing); otherwise set
|
|
100
|
+
provider keys via `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `SOLROUTER_API_KEY`.
|
|
101
|
+
|
|
102
|
+
## What this will not do
|
|
103
|
+
|
|
104
|
+
It never reformulates, splits, or disguises a request to make a frontier model
|
|
105
|
+
comply with something it would otherwise refuse. Refused work goes to a *different
|
|
106
|
+
model*, openly. That's the honest and durable answer — and it's what keeps your
|
|
107
|
+
account, and everyone else's on the same backend, from being banned. See `SPEC.md` §7.
|
|
108
|
+
|
|
109
|
+
## Develop
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
node --test 'test/*.test.js'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Note
|
|
116
|
+
|
|
117
|
+
`src/pseudonymise.js` is vendored from `routerlabs/lib/pseudonymise.js` — keep both
|
|
118
|
+
in sync until it's published as a shared package. Any dependency added to the
|
|
119
|
+
masking path is a deployment blocker (it has to run inside the user's perimeter).
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SolRouter Proxy — architecture spec (draft for review)
|
|
2
|
+
|
|
3
|
+
> Status: **proposal, awaiting Noah's approval.** No further code until this is signed off.
|
|
4
|
+
> Written 2026-09-14, informed by four research passes (routing, refusal prediction,
|
|
5
|
+
> reversible PII masking, task decomposition). Sources are cited inline next to the
|
|
6
|
+
> decision they drive, so each claim is checkable.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. What this is
|
|
11
|
+
|
|
12
|
+
A routing proxy that gives a developer the strongest model that *will* do their work,
|
|
13
|
+
without handing that model anything it doesn't need to know about who the work is for.
|
|
14
|
+
|
|
15
|
+
Two things happen to every request, locally, before it leaves the machine:
|
|
16
|
+
|
|
17
|
+
1. **Pseudonymise** — names, IDs, secrets, financial identifiers replaced with realistic
|
|
18
|
+
surrogates. The mapping never leaves.
|
|
19
|
+
2. **Route** — the request goes to a frontier model, unless a frontier model would refuse
|
|
20
|
+
it, in which case it goes to an open-weight uncensored model the user controls.
|
|
21
|
+
|
|
22
|
+
The result is restored locally and written to a hash-chained receipt.
|
|
23
|
+
|
|
24
|
+
## 2. Goals and non-goals
|
|
25
|
+
|
|
26
|
+
**Goals**
|
|
27
|
+
- **User agency.** The user decides where each request runs; the proxy never blocks. If the
|
|
28
|
+
frontier won't do it, the open leg will.
|
|
29
|
+
- **Privacy without losing capability.** The strong model does the hard thinking but never
|
|
30
|
+
learns identities. Privacy and quality stop being a tradeoff.
|
|
31
|
+
- **Run-anywhere.** Masking is zero-dependency and runs on the user's machine. The routing
|
|
32
|
+
brain runs in the SolRouter TEE. No plaintext reaches a third-party provider.
|
|
33
|
+
- **Auditability.** One verifiable receipt per call: what was masked, where it ran, a hash of
|
|
34
|
+
what the provider actually saw.
|
|
35
|
+
|
|
36
|
+
**Non-goals — explicit**
|
|
37
|
+
- **Not a frontier-guardrail bypass.** The proxy never reformulates, splits, or disguises a
|
|
38
|
+
request to make a frontier model comply with something it would otherwise refuse. Refused
|
|
39
|
+
work goes to a *different model*, openly. This is the load-bearing ethical and ToS line:
|
|
40
|
+
decomposition-to-evade is how an org's API key gets banned, and it is out of scope. See §7.
|
|
41
|
+
- Not a hosted gateway that sees plaintext (breaks the privacy claim; rejected in review).
|
|
42
|
+
|
|
43
|
+
## 3. The three reasons to route — and which the research supports
|
|
44
|
+
|
|
45
|
+
This is the most important section, because the literature treats these very differently.
|
|
46
|
+
|
|
47
|
+
| Split by… | Rationale | Research verdict |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| **Permission** | Frontier won't do it; open leg will | **Sound.** Not a quality bet — the open model is the only option for that segment, so the quality-diversity critique below doesn't apply. |
|
|
50
|
+
| **Privacy** | User doesn't want the provider to see this at all | **Sound.** User's choice; the open leg in the TEE sees only what the user permits. |
|
|
51
|
+
| **Quality / cost** | A cheaper/smaller model is "good enough" here | **Handle with care.** This is where mixing models quietly loses quality. |
|
|
52
|
+
|
|
53
|
+
Why "quality/cost" is dangerous: **Self-MoA** (arXiv:2502.00674) shows aggregating the single
|
|
54
|
+
best model beats mixed-model ensembles (+6.6% AlpacaEval, +3.8% avg) — the *quality-diversity
|
|
55
|
+
tradeoff*: adding weaker models for diversity drags average quality down. **MAST**
|
|
56
|
+
(arXiv:2503.13657, 200+ traces) finds multi-agent gains "often minimal" and failures dominated
|
|
57
|
+
by system design, not model weakness. **Cognition** ("Don't Build Multi-Agents") shows parallel
|
|
58
|
+
isolated segments merge incoherently.
|
|
59
|
+
|
|
60
|
+
**Design consequence:** the orchestrator splits a task **only** when a segment is (a) genuinely
|
|
61
|
+
independent read/breadth work, or (b) needs the permission/privacy leg. It does **not** split a
|
|
62
|
+
coherent task across vendors to chase quality. Default is *one* strong model for the whole task,
|
|
63
|
+
pseudonymised.
|
|
64
|
+
|
|
65
|
+
## 4. Architecture — the boundary (decision: local mask + TEE router)
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌─ USER MACHINE (laptop / VPC) ─────────────┐ ┌─ SOLROUTER TEE ──────────┐ ┌─ PROVIDERS ─┐
|
|
69
|
+
│ proxy (OpenAI/Anthropic-compatible) │ │ router brain │ │ Claude/GPT/ │
|
|
70
|
+
│ │ │ · refusal predictor │ │ Gemini │
|
|
71
|
+
│ request │ │ (guard model) │ │ (frontier) │
|
|
72
|
+
│ └─ 1. detect + pseudonymise ─ masked ───┼──▶│ · refusal detector │ │ │
|
|
73
|
+
│ map stays here ◀───────────────────┼───┤ (WildGuard-class) │ │ Qwen │
|
|
74
|
+
│ │ │ · decompose (if any) │◀──┼─ uncensored │
|
|
75
|
+
│ ┌─ restored answer ◀── masked answer ───┼───┤ routes each segment: │──▶│ (open-weight,│
|
|
76
|
+
│ ▼ (fuzzy restore, streaming-safe) │ │ frontier | private │ │ Nosana/TEE) │
|
|
77
|
+
│ 2. receipt (hash-chained, → Solana attest)│ │ reactive retry on │ │ │
|
|
78
|
+
└───────────────────────────────────────────┘ │ detected refusal │ └──────────────┘
|
|
79
|
+
└──────────────────────────┘
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Plaintext never leaves the user machine.** Only surrogate text crosses any boundary.
|
|
83
|
+
- **Masking is local** (zero-dependency, ships into the perimeter). The **routing brain is in
|
|
84
|
+
the TEE**, so a larger classifier can decide than a laptop could run, and the frontier
|
|
85
|
+
provider is reached from the TEE, never given plaintext.
|
|
86
|
+
- Masked text is safe to send to the TEE router *and* to providers — masking removes identity,
|
|
87
|
+
not the content the router needs to judge refusal.
|
|
88
|
+
|
|
89
|
+
## 5. Components
|
|
90
|
+
|
|
91
|
+
### 5.1 Masker (local, zero-dependency)
|
|
92
|
+
- Base: current `pseudonymise.js` (IBAN mod-97, Luhn, EN/DE detectors) + `secrets.js`
|
|
93
|
+
(API keys, JWTs, PEM, connection strings, internal hosts).
|
|
94
|
+
- **CHANGE from current code — realistic surrogates, not `«PERSON_1»` tokens.** Surrogate
|
|
95
|
+
substitution beats placeholders by **+13.26 BERTScore** at equal privacy (SurrogateShield,
|
|
96
|
+
arXiv:2606.29567); Anonymous-by-Construction (arXiv:2603.17217) reaches 0.95 Q&A accuracy vs
|
|
97
|
+
0.82 for redaction. Generate type-consistent fake values (name→name, IBAN→valid-format IBAN),
|
|
98
|
+
preserving format/gender/number. Reference implementation to study: LangChain
|
|
99
|
+
`PresidioReversibleAnonymizer` (Faker operators + instance mapping).
|
|
100
|
+
- **Deterministic, consistent mapping** — same original → same surrogate for the session, or the
|
|
101
|
+
model contradicts itself. Map is instance-level, local, short-TTL.
|
|
102
|
+
- **Structural masking of tool-call JSON and code**, not regex-over-blob — this is the biggest
|
|
103
|
+
silent leak in real redactors (dev.to/crp4222 tool-call-args writeup; truefoundry gateway
|
|
104
|
+
analysis). Mask: system prompt, all message text, tool_call arguments, tool results, tool
|
|
105
|
+
*definitions*.
|
|
106
|
+
- **Recall gap to note:** regex+NER (Presidio-class) is ~0.56 privacy recall — misses
|
|
107
|
+
contextual identifiers ("the Leverkusen client from March"). Optionally add an LLM-in-TEE
|
|
108
|
+
detector pass for recall; document the limit either way (the masker already carries a STATED
|
|
109
|
+
LIMITS block — keep it).
|
|
110
|
+
|
|
111
|
+
### 5.2 Restorer (local, streaming-safe)
|
|
112
|
+
- **Fuzzy / n-gram matching, never exact replace** — models re-case, split, or paraphrase the
|
|
113
|
+
value (LangChain graded strategies: exact → case-insensitive → fuzzy → n-gram). Realistic
|
|
114
|
+
surrogates survive round-trips better than tokens.
|
|
115
|
+
- **Streaming:** buffer at chunk boundaries; a surrogate can be split across chunks. Restore
|
|
116
|
+
over a reassembled boundary-safe window, not per token. (qaskills.sh streaming-redaction.)
|
|
117
|
+
- Leak check: assert no residual surrogate tokens in assembled output.
|
|
118
|
+
|
|
119
|
+
### 5.3 Router brain (TEE)
|
|
120
|
+
- **Reactive detection is primary, prediction is an optimization.** Predicting frontier refusal
|
|
121
|
+
from the prompt is *not* a solved, off-the-shelf capability: guard models classify **harm**,
|
|
122
|
+
but frontier refusal is idiosyncratic and over-fires on **25-40% of benign prompts**
|
|
123
|
+
(arXiv:2605.05427; over-refusal benchmarks XSTest 2308.01263, OR-Bench 2405.20947). So:
|
|
124
|
+
1. **Predict (cheap, optional):** a small guard (Llama Guard 3 1B / ShieldGemma 2B) flags
|
|
125
|
+
obvious cases and skips the wasted frontier round-trip.
|
|
126
|
+
2. **Reactive (authoritative):** send masked prompt to frontier; **detect refusal** with a
|
|
127
|
+
substring pre-filter (AdvBench/GCG prefix list) + a small classifier (WildGuard-7B, the
|
|
128
|
+
only open system near GPT-4 on refusal detection, arXiv:2406.18495); on refusal, retry on
|
|
129
|
+
the open leg. Substrings alone are unreliable ("Sure, here…" then refuses) — use both.
|
|
130
|
+
- **Route selection when routing by quality/cost** (not permission): binary strong/weak is
|
|
131
|
+
RouteLLM's exact problem (arXiv:2406.18665) — MF router hits 95% GPT-4 quality at 14-26%
|
|
132
|
+
frontier calls on chat traffic, and *transfers across model pairs without retraining*, so it's
|
|
133
|
+
a credible off-the-shelf start. Gains are large on mixed-difficulty traffic, thin on uniform
|
|
134
|
+
knowledge tests — measure before assuming.
|
|
135
|
+
- **Explicit override always wins:** `x-route: frontier|private|auto`. Default `auto`.
|
|
136
|
+
|
|
137
|
+
### 5.4 Orchestrator / decomposer (TEE) — conservative by default
|
|
138
|
+
- Off unless the task needs it (§3). When on: strong-model planner emits **explicit subtask
|
|
139
|
+
contracts** (objective, output format, boundaries) — the single highest-leverage artifact per
|
|
140
|
+
Anthropic's multi-agent write-up (+90.2% on research tasks, but read/breadth only). Parallelise
|
|
141
|
+
**reads**, never shared **writes** (Cognition). Pass full traces, not just messages. End with a
|
|
142
|
+
**synthesis + verification pass** (no reconciliation trick substitutes for it — MAST).
|
|
143
|
+
- Per-segment routing uses §5.3, with the permission/quality distinction from §3.
|
|
144
|
+
|
|
145
|
+
### 5.5 Receipt (local + Solana)
|
|
146
|
+
- One hash-chained JSONL line per call: labels + counts only (never raw values or the map), which
|
|
147
|
+
leg ran it, why, `sha256` of the provider payload, refusal flag, `prev` hash.
|
|
148
|
+
- Anchor the chain head to Solana via existing `encrypted-storage` / Light Protocol attestation.
|
|
149
|
+
- Safe to show an auditor: it proves what was protected and where it ran without containing PII.
|
|
150
|
+
|
|
151
|
+
## 6. What to measure before claiming anything
|
|
152
|
+
- **Over-refusal / prediction accuracy:** XSTest, OR-Bench, FalseReject — *not* harm benchmarks.
|
|
153
|
+
These are the prompts that silently break routing.
|
|
154
|
+
- **Routing quality/cost:** RouterBench, APGR vs frontier-call fraction (RouteLLM frame).
|
|
155
|
+
- **Masking utility:** BERTScore / task accuracy, masked vs unmasked, per SurrogateShield frame.
|
|
156
|
+
- **Restore fidelity:** residual-token rate, fuzzy-match precision on paraphrased surrogates.
|
|
157
|
+
|
|
158
|
+
## 7. What this system will not do (and why it's also good product)
|
|
159
|
+
No reformulation, decomposition, or obfuscation whose purpose is to make a frontier model comply
|
|
160
|
+
with what it would refuse if it saw the whole request. Reasons, in order of who they protect:
|
|
161
|
+
1. **The user** — it doesn't even work: guardrails judge meaning, not the masked name in the text.
|
|
162
|
+
2. **Router Labs** — provider misuse detection bans the *org key*, taking the frontier leg from
|
|
163
|
+
every customer at once. Steady refusals/evasion traffic is the signature.
|
|
164
|
+
3. **The enterprise story** — a "get past refusals" feature found during due diligence sinks the
|
|
165
|
+
compliance sale that funds everything else.
|
|
166
|
+
The uncensored leg is the honest answer to "the frontier won't do this": a different model,
|
|
167
|
+
openly, that the user controls.
|
|
168
|
+
|
|
169
|
+
## 8. PoC phases (proposed, post-approval)
|
|
170
|
+
1. **P0 — local proxy, prose only:** OpenAI/Anthropic dialects, surrogate masking, reactive
|
|
171
|
+
refusal detection (substrings), fuzzy restore, receipt. Demo: Claude Code on a repo with a
|
|
172
|
+
`.env`; split screen provider-view vs user-view.
|
|
173
|
+
2. **P1 — TEE router:** move detection/prediction into the TEE; add WildGuard-class classifier;
|
|
174
|
+
`x-route` + `x-intent` headers.
|
|
175
|
+
3. **P2 — tool-call + streaming:** structural JSON masking, streaming-safe restore.
|
|
176
|
+
4. **P3 — decomposition:** conservative orchestrator for independent/permission segments only.
|
|
177
|
+
5. **P4 — SDK middleware:** fold the core into `@solrouter/sdk` as opt-in.
|
|
178
|
+
|
|
179
|
+
## 8a. Resolved from the backend (2026-09-14)
|
|
180
|
+
- **Private leg contract:** `POST {backend}/agent`, `Authorization: Bearer
|
|
181
|
+
sk_solrouter_...`, body `{prompt, model:"qwen3.8:27b", useTools:false}`, stateless
|
|
182
|
+
for API-key users, returns `{success, reply}`. Verified in `routes/agent.js` +
|
|
183
|
+
`middleware/apiKeyAuth.js`. There is no plaintext-messages route for the Qwen node
|
|
184
|
+
and no `/cli/verify` to build — the `sk_solrouter_` key IS the auth.
|
|
185
|
+
- **Model:** `qwen3.8:27b` (uncensored); legacy `qwen3-8b`/`qwen3:8b` alias to it
|
|
186
|
+
(`routes/nosana.js` ALIASES). Priced in `routes/private-ai-api.js`.
|
|
187
|
+
- **`/api/v1/chat/completions`** is OpenAI-named but requires an Arcium
|
|
188
|
+
`encryptedPrompt`, so it's not the plaintext drop-in; `/agent` is. An encrypted
|
|
189
|
+
private leg via `@solrouter/sdk` (E2E to the TEE) is the P1 upgrade.
|
|
190
|
+
|
|
191
|
+
## 9. Open questions for Noah
|
|
192
|
+
- Surrogate library: hand-rolled type-consistent generators (keeps zero-dependency) vs pulling a
|
|
193
|
+
Faker-class dependency into the *local* masker (a deployment-boundary cost — see the routerlabs
|
|
194
|
+
note that any dependency in the masker is a blocker). Recommendation: hand-rolled.
|
|
195
|
+
- Does the open/uncensored leg run on Nosana, in the Phala TEE, or both? Affects where "private"
|
|
196
|
+
actually executes and what the receipt can attest.
|
|
197
|
+
- Is P0 single-user local, or do we demo the TEE router from day one? (Answered "local mask + TEE
|
|
198
|
+
router" for architecture; question is whether P0 stubs the TEE or uses it live.)
|
|
199
|
+
```
|
package/TESTING.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Testing the proxy (no account needed)
|
|
2
|
+
|
|
3
|
+
Everything below runs locally with **no SolRouter login**. The frontier leg uses
|
|
4
|
+
your own key; the private/uncensored leg needs a login (endpoint not built yet — see
|
|
5
|
+
bottom).
|
|
6
|
+
|
|
7
|
+
Requires Node 22+. From `dev/backend/packages/router-proxy/`:
|
|
8
|
+
|
|
9
|
+
## 1. Prove masking works — zero setup, no key
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node bin/proxy.js demo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Spins a local mock provider, sends a message full of real PII through the full
|
|
16
|
+
pipeline, and prints: what you typed, what the provider actually received
|
|
17
|
+
(surrogates), whether anything real leaked (should say `NONE ✓`), and the receipt.
|
|
18
|
+
Nothing leaves your machine.
|
|
19
|
+
|
|
20
|
+
Run the unit tests too:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm test
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 2. Run it against a real frontier model (your own key)
|
|
27
|
+
|
|
28
|
+
The frontier leg passes your own credentials through — nothing to configure but the key.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export ANTHROPIC_API_KEY=sk-ant-... # your existing key
|
|
32
|
+
node bin/proxy.js start # http://127.0.0.1:8787
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Point a tool at it, or curl it directly:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
curl http://127.0.0.1:8787/v1/messages \
|
|
39
|
+
-H "content-type: application/json" \
|
|
40
|
+
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
|
41
|
+
-H "anthropic-version: 2023-06-01" \
|
|
42
|
+
-d '{
|
|
43
|
+
"model": "claude-opus-5",
|
|
44
|
+
"max_tokens": 300,
|
|
45
|
+
"messages": [{ "role": "user",
|
|
46
|
+
"content": "Write a payment reminder to Margaret Ellison (margaret@acme.co) for IBAN DE44500105175407324931." }]
|
|
47
|
+
}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You get a normal answer with the real name/IBAN in it. To see what the model
|
|
51
|
+
*actually* received, check the receipt hash and detected labels:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
node bin/proxy.js verify
|
|
55
|
+
cat ~/.solrouter/receipts.jsonl | tail -1
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`detected` lists the labels masked (PERSON, EMAIL, IBAN…); the model never saw the
|
|
59
|
+
real values.
|
|
60
|
+
|
|
61
|
+
## 3. Wire it into a coding tool
|
|
62
|
+
|
|
63
|
+
Set the tool's base URL and keep using your own key:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# OpenAI-compatible (Cursor, Cline, Continue, OpenAI SDK)
|
|
67
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
68
|
+
|
|
69
|
+
# Anthropic-compatible (Claude Code)
|
|
70
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Per-request control:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
-H "x-route: private" # force the uncensored leg (needs login)
|
|
77
|
+
-H "x-route: frontier" # force the frontier model
|
|
78
|
+
-H "x-mode: private" # private for this whole request
|
|
79
|
+
-H "x-intent: authorized pentest, scope acme.com" # frontier leg only
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 4. Test the private/uncensored leg (real backend)
|
|
83
|
+
|
|
84
|
+
The private leg calls the SolRouter backend's `/agent` route with a `sk_solrouter_`
|
|
85
|
+
key — the same key type the backend already issues. No new endpoint needed.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
solrouter login --token sk_solrouter_... # your SolRouter API key
|
|
89
|
+
# force the uncensored leg for a request:
|
|
90
|
+
curl http://127.0.0.1:8787/v1/chat/completions \
|
|
91
|
+
-H "content-type: application/json" \
|
|
92
|
+
-H "x-route: private" \
|
|
93
|
+
-d '{"model":"x","messages":[{"role":"user","content":"..."}]}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
It hits `POST {backend}/agent` with `{prompt, model:"qwen3.8:27b", useTools:false}`,
|
|
97
|
+
stateless (no chatId needed for API-key users). `x-route: auto` (the default) tries
|
|
98
|
+
the frontier model first and reroutes here if it refuses.
|
|
99
|
+
|
|
100
|
+
Requires the backend + its Nosana/TEE node to be up (it's the live prod backend by
|
|
101
|
+
default). If the Qwen node is cold, see `scripts/NOSANA_RUNBOOK.md`.
|
|
102
|
+
|
|
103
|
+
## What needs a live backend vs. runs anywhere
|
|
104
|
+
|
|
105
|
+
- **Anywhere, no account:** `demo`, `npm test`, masking/restore/receipts, and the
|
|
106
|
+
frontier leg with your own key (§2–3).
|
|
107
|
+
- **Needs a `sk_solrouter_` key + backend up:** the private/uncensored leg (§4).
|
|
108
|
+
|
|
109
|
+
The only not-yet-built convenience is the browser `login` (loopback OAuth); the
|
|
110
|
+
`--token` path is complete and is what makes the private leg work today.
|
package/bin/proxy.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// solrouter — CLI. Same shape as Claude Code / gh:
|
|
3
|
+
// solrouter login verify your account in the browser
|
|
4
|
+
// solrouter start run the local proxy (default command)
|
|
5
|
+
// solrouter status show config + login state
|
|
6
|
+
// solrouter verify check the receipt hash-chain
|
|
7
|
+
// solrouter logout
|
|
8
|
+
|
|
9
|
+
import { loadConfig, isLoggedIn, loadAuth, HOME_DIR } from "../src/config.js";
|
|
10
|
+
import { browserLogin, loginWithToken } from "../src/auth.js";
|
|
11
|
+
import { startServer } from "../src/server.js";
|
|
12
|
+
import { verifyChain } from "../src/receipt.js";
|
|
13
|
+
import { runDemo } from "../src/demo.js";
|
|
14
|
+
import { rmSync, existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const [, , cmdRaw, ...rest] = process.argv;
|
|
18
|
+
const cmd = cmdRaw || "start";
|
|
19
|
+
const flag = name => { const i = rest.indexOf(name); return i >= 0 ? (rest[i + 1] ?? true) : undefined; };
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
switch (cmd) {
|
|
23
|
+
case "login": {
|
|
24
|
+
const token = flag("--token");
|
|
25
|
+
if (token && token !== true) {
|
|
26
|
+
loginWithToken(token, flag("--account") || null);
|
|
27
|
+
console.log("Saved. You're logged in.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const t = await browserLogin();
|
|
32
|
+
console.log(`Logged in${t.account ? " as " + t.account : ""}.`);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error("Login failed:", e.message);
|
|
35
|
+
console.error("Stopgap: solrouter login --token <your SolRouter API key>");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case "logout": {
|
|
42
|
+
const p = join(HOME_DIR, "auth.json");
|
|
43
|
+
if (existsSync(p)) rmSync(p);
|
|
44
|
+
console.log("Logged out.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case "status": {
|
|
49
|
+
const cfg = loadConfig();
|
|
50
|
+
const a = loadAuth();
|
|
51
|
+
console.log("SolRouter proxy");
|
|
52
|
+
console.log(" logged in :", isLoggedIn() ? `yes${a.account ? " (" + a.account + ")" : ""}` : "no");
|
|
53
|
+
console.log(" port :", cfg.port);
|
|
54
|
+
console.log(" mode :", cfg.mode);
|
|
55
|
+
console.log(" frontier :", cfg.frontier.model, "→", cfg.frontier.baseUrl);
|
|
56
|
+
console.log(" private :", cfg.private.model, "→", cfg.private.baseUrl);
|
|
57
|
+
console.log(" masking :", cfg.masking?.enabled === false ? "off" : "on");
|
|
58
|
+
console.log(" receipts :", cfg.receipt?.path);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case "demo": {
|
|
63
|
+
const ok = await runDemo();
|
|
64
|
+
process.exit(ok ? 0 : 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case "verify": {
|
|
68
|
+
const cfg = loadConfig();
|
|
69
|
+
const r = verifyChain(cfg.receipt.path);
|
|
70
|
+
if (r.ok) console.log(`Receipt chain OK — ${r.count} record(s).`);
|
|
71
|
+
else { console.error(`Receipt chain BROKEN at record ${r.brokenAt} (${r.why}).`); process.exit(1); }
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "start":
|
|
76
|
+
default: {
|
|
77
|
+
const cfg = loadConfig({ port: flag("--port") ? Number(flag("--port")) : undefined,
|
|
78
|
+
mode: flag("--mode") || undefined });
|
|
79
|
+
// Pass-through supplies frontier creds per request, so login is not required
|
|
80
|
+
// to start — it only unlocks the private/uncensored leg.
|
|
81
|
+
if (!isLoggedIn()) {
|
|
82
|
+
console.warn("Note: not logged in — the private/uncensored leg is unavailable.");
|
|
83
|
+
console.warn(" The frontier leg uses your tool's own credentials. Run `login` to enable both.\n");
|
|
84
|
+
}
|
|
85
|
+
startServer(cfg);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 8787,
|
|
3
|
+
"mode": "auto",
|
|
4
|
+
"frontier": {
|
|
5
|
+
"baseUrl": "https://api.anthropic.com",
|
|
6
|
+
"dialect": "anthropic",
|
|
7
|
+
"model": "claude-opus-5"
|
|
8
|
+
},
|
|
9
|
+
"private": {
|
|
10
|
+
"baseUrl": "https://solrouter-obb4.onrender.com",
|
|
11
|
+
"dialect": "openai",
|
|
12
|
+
"model": "qwen3.8:27b"
|
|
13
|
+
},
|
|
14
|
+
"masking": {
|
|
15
|
+
"enabled": true,
|
|
16
|
+
"names": ["Acme Holdings", "Margaret Ellison"],
|
|
17
|
+
"guessNames": true
|
|
18
|
+
},
|
|
19
|
+
"receipt": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"path": "~/.solrouter/receipts.jsonl"
|
|
22
|
+
},
|
|
23
|
+
"viaSolrouter": true
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "solrouter",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"solrouter": "bin/proxy.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"SPEC.md",
|
|
17
|
+
"TESTING.md",
|
|
18
|
+
"config.example.json"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node bin/proxy.js",
|
|
22
|
+
"demo": "node bin/proxy.js demo",
|
|
23
|
+
"test": "node --test 'test/*.test.js'",
|
|
24
|
+
"prepublishOnly": "node --test 'test/*.test.js'"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"solrouter", "privacy", "llm", "proxy", "pseudonymize", "pii", "claude", "openai", "router"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// auth.js — "download, verify in the browser, ship" login, same shape as the
|
|
2
|
+
// Claude Code / gh CLI flow.
|
|
3
|
+
//
|
|
4
|
+
// The CLI opens a loopback server on a random port, opens the browser to the
|
|
5
|
+
// SolRouter verify page with that port as the redirect target, the user approves
|
|
6
|
+
// in the browser, the page redirects back to the loopback with a token, and the
|
|
7
|
+
// CLI saves it. A `state` nonce ties the callback to this attempt.
|
|
8
|
+
//
|
|
9
|
+
// The real auth is a SolRouter API key (`sk_solrouter_...`, bcrypt-verified by the
|
|
10
|
+
// backend). `login --token sk_solrouter_...` saves one and the private leg works.
|
|
11
|
+
// The browser loopback flow below is a future convenience that mints/returns such a
|
|
12
|
+
// key without a paste; it needs a small backend verify page, which is optional —
|
|
13
|
+
// the token path is complete on its own.
|
|
14
|
+
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { exec } from "node:child_process";
|
|
18
|
+
import { saveAuth } from "./config.js";
|
|
19
|
+
|
|
20
|
+
const VERIFY_BASE = process.env.SOLROUTER_VERIFY_URL || "https://solrouter.com/cli/auth";
|
|
21
|
+
|
|
22
|
+
function openBrowser(url) {
|
|
23
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
24
|
+
: process.platform === "win32" ? "start \"\"" : "xdg-open";
|
|
25
|
+
exec(`${cmd} "${url}"`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Loopback browser login. Resolves once the callback delivers a token.
|
|
29
|
+
* `open` is injectable for testing (defaults to launching the real browser). */
|
|
30
|
+
export function browserLogin({ timeoutMs = 180000, open = openBrowser } = {}) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const state = randomBytes(16).toString("hex");
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
35
|
+
if (url.pathname !== "/callback") { res.writeHead(404).end(); return; }
|
|
36
|
+
if (url.searchParams.get("state") !== state) {
|
|
37
|
+
res.writeHead(400).end("state mismatch"); return;
|
|
38
|
+
}
|
|
39
|
+
const token = {
|
|
40
|
+
access_token: url.searchParams.get("token"),
|
|
41
|
+
account: url.searchParams.get("account") || null,
|
|
42
|
+
expires_at: Number(url.searchParams.get("expires_at")) || null,
|
|
43
|
+
};
|
|
44
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
45
|
+
res.end("<h2>SolRouter proxy connected.</h2><p>You can close this tab and return to the terminal.</p>");
|
|
46
|
+
server.close();
|
|
47
|
+
if (!token.access_token) return reject(new Error("no token in callback"));
|
|
48
|
+
saveAuth(token);
|
|
49
|
+
resolve(token);
|
|
50
|
+
});
|
|
51
|
+
server.listen(0, "127.0.0.1", () => {
|
|
52
|
+
const port = server.address().port;
|
|
53
|
+
const redirect = `http://127.0.0.1:${port}/callback`;
|
|
54
|
+
const verifyUrl = `${VERIFY_BASE}?redirect_uri=${encodeURIComponent(redirect)}&state=${state}`;
|
|
55
|
+
console.log("Opening your browser to verify your SolRouter account…");
|
|
56
|
+
console.log("If it doesn't open, visit:\n " + verifyUrl);
|
|
57
|
+
open(verifyUrl);
|
|
58
|
+
});
|
|
59
|
+
setTimeout(() => { server.close(); reject(new Error("login timed out")); }, timeoutMs);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stopgap: save a pasted token directly. */
|
|
64
|
+
export function loginWithToken(token, account = null) {
|
|
65
|
+
saveAuth({ access_token: token, account, expires_at: null });
|
|
66
|
+
return { access_token: token, account };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default { browserLogin, loginWithToken };
|