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 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 };