Back to the app

Testing routstr confidential mode (protocol v3)

For people who want to try it: users first, then developers, then node operators.

Experimental. This is new cryptography that nobody outside the project has reviewed yet. The code is on forks, not in Routstr releases. Test with small amounts (a few hundred sats), and expect bugs.

1. What this is

A routstr node normally sees everything you send to the AI model and everything it answers. In confidential mode the node only relays encrypted traffic between you and the provider (Venice, for now). It cannot read your prompt or the answer, and you never get the node's provider API key. Zero-knowledge proofs make sure neither side cheats, and you pay for the usage the provider itself reported.

This guide covers protocol v3. A connection to the provider can now carry several requests: over HTTP/2 the expensive setup runs once and later messages reuse it. Over HTTP/1.1 every request gets its own connection. None of this is merged into upstream Routstr yet, so every step below uses forks (see the branch map).

Background reading, all on GitHub:

2. End users: the web app

Nothing to install. The app does all the cryptography in your browser.

Get started

  1. Open jooray.github.io/routstr-confidential-pwa. The default node is https://routstr.cypherpunk.today, which runs protocol v3 since 2026-11-12 (HTTP/2 and HTTP/1.1, a connection deposit of 2 sats). The header should say offer verified: the app checked the node's signed price list against the node key it has pinned.
  2. Fund it in the Wallet panel. You have three options:
    • Lightning: enter an amount in sats (500 is filled in) and press Lightning. Pay the invoice (QR code or copy). Once it is paid, the node creates a key for you, or tops up the one you have. Your wallet may add a routing fee.
    • Pay with Cashu: paste a cashuA… or cashuB… token and press Create key (or Top up). The panel lists the mints the node accepts and any mint fee.
    • Use an existing key: paste an sk-… key you already have on this node.
    The key lives only in this tab unless you tick Remember on this device under Your key. Copy it somewhere if you leave money on it. Withdraw (refund) returns the whole balance as a Cashu token or to a Lightning address.
  3. Pick a model, type a message and send it.

Reading the result

Under each answer there is a line like this:

verified  HTTP/2 · #2  kept connection  first token 0.6 s  total 1.4 s  21/48 tok  0.04 sat  [details]
PartMeaning
verifiedEvery check passed: the provider's certificate, the proofs, the node's signed receipt, and a cost within the signed prices. If a check fails you get an error here instead.
HTTP/2 · #2 or HTTP/1.1How the browser talked to the provider (the "framing"). With HTTP/2, #2 is the request's number on its connection.
new / kept / prepared connectionnew: this message opened a connection (slow, about 18 MB of setup data). kept: it reused an open HTTP/2 connection (fast). prepared: it used a connection the app had set up ahead (see pre-warming below).
tokens and costFrom the signed receipt.

Press details to open the Verification panel. It shows the provider's certificate chain, the proofs (π_NK, π_WIN, π_C3S), and the Receipt card: usage, cost, the session fee, the connection deposit and what happened to it, and the connection row (the first characters of the connection id sid and the request number n). The full signed receipt JSON is at the bottom of that card. A timeline shows where the time went.

HTTP/2 or HTTP/1.1

Open the Node panel and find HTTP to the provider: auto (HTTP/2 when the node offers it), HTTP/2 only, or HTTP/1.1 (HTTP/2 off). The pill in the header shows what your next message will use.

If HTTP/2 fails to open on a node, the app uses HTTP/1.1 for that node for 10 minutes and tells you so.

The connection deposit

Opening a connection costs the node real bandwidth before you have sent anything. So the node holds a small connection deposit when a connection opens (the Node panel and the Costs panel show the amount).

Pre-warming (Costs panel, Keep a connection prepared) is off by default: on HTTP/2 the open connection already stays warm between messages, for about two minutes of idle. When you turn it on, after your first answer the app prepares one connection in the background while the page is visible, so the next message after a pause is fast. An unused prepared connection costs its deposit. The app does not pre-warm when the deposit is above 5 sats, on a metered connection, or when your balance cannot cover the deposit plus one message.

Images

Switch to Image mode above the message box. Pick a model, resolution, aspect ratio and count. The node sees the model, the resolution and the count, because it charges for those. It does not see the prompt or the image. The price is a flat amount per image from the signed offer. Images are kept in this browser's storage with their prompt, so delete them from the gallery when you are done. For vision models, the Image button in chat mode attaches a picture to your message. That picture is sent inside the encrypted request, too.

What a failure looks like

You seeWhat it means
A yellow banner: This node runs an old protocol (routstr-confidential v2); this app speaks v3, and the pill node outdated (v2)The node has not been upgraded. Nothing is sent to it. Pick another node or wait for the operator.
The node speaks a newer protocol than this app. Reload the page to update the app.The app should update itself within minutes. Reload if it does not.
This app and the node run different versions of the two-party key scheduleThe app and the node are from different releases. Reload; if it stays, report it.
Not enough balance for the connection deposit or for this request's reservationTop up, or lower max tokens. While a request runs the node holds its worst-case cost and then releases the rest.
The node already holds too many unused connections for your addressOther tabs or devices behind the same IP address hold connections. Close them or wait a few minutes.
offer rejected in the headerThe node's signed offer did not verify against the pinned key or the trusted provider. Do not use the node until you know why.

To try another node, open the Node panel, set the node URL, its key (hex or npub) and the trusted provider host, then press Apply. Defaults goes back to routstr.cypherpunk.today.

Report problems as GitHub issues on jooray/routstr-confidential. See what to include.

The app protects you from the node, not from whoever serves the page. The footer shows the app and library commits, and version.json lists a hash over every file. Keep balances small.

3. End users: routstrd, for any app

routstrd is a local daemon with an OpenAI-compatible API on http://127.0.0.1:8008/v1. Any app that can set a base URL and one extra header can use confidential mode through it. For now you have to build it from source, together with the SDK and two helper programs.

You need: git, Bun, Node.js with npm, and Rust through rustup (the repository pins its toolchain, 1.97.1, and rustup fetches it).

Build

Put the three checkouts next to each other. The SDK link below relies on that.

mkdir confidential && cd confidential

# 1. The helper programs cu-prover and cu-2pc (they must be from the same release as the node's sidecar)
git clone -b main https://github.com/jooray/routstr-confidential
(cd routstr-confidential/crypto && \
  cargo build --release -p zkbench --bin cu-prover -p twopc --bin cu-2pc)
# result: routstr-confidential/crypto/zkbench/target/release/cu-prover and cu-2pc

# 2. The SDK with the confidential transport (not on npm yet)
git clone -b confidential-2pc https://github.com/jooray/routstr-sdk
(cd routstr-sdk && npm install && npm run build)

# 3. routstrd, built against that SDK
git clone -b confidential-upstream https://github.com/jooray/routstrd
cd routstrd
bun install
rm -rf node_modules/@routstr/sdk && ln -s "$PWD/../routstr-sdk" node_modules/@routstr/sdk
bun run build       # after the link, so dist/ bundles the SDK
bun link            # installs `routstrd` into ~/.bun/bin (put it on your PATH)

The binaries land in crypto/zkbench/target/release/, not in crypto/target (the repository sets that build directory). Keep cu-prover and cu-2pc in one directory: the SDK looks for cu-2pc next to cu-prover, or in CU_2PC_BIN / CU_2PC. Without the SDK link the daemon fails at startup with an Export named … not found error. After you update either checkout, run npm run build in routstr-sdk, then bun run build in routstrd.

Fund and configure

routstrd onboard
routstrd receive <cashu token>    # or: routstrd receive 1000   (a Lightning invoice for 1000 sats to pay)

Then add this block to ~/.routstrd/config.json (keep the keys that are already there):

"confidentialUpstream": {
  "enabled": true,
  "trusted_hosts": ["api.venice.ai"],
  "node_pubkeys": ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
  "prover_path": "/absolute/path/to/routstr-confidential/crypto/zkbench/target/release/cu-prover",
  "http": "auto",
  "maxConnectionDepositMsat": 100000,
  "http2": { "reuse": "per-client" },
  "prewarm": { "enabled": true }
}
KeyDefaultMeaning
trusted_hosts(none)Provider hostnames whose TLS certificate you accept. Empty refuses every session.
node_pubkeys(from Nostr)Node keys whose signed offers you accept. The key above is routstr.cypherpunk.today's. Without this list, routstrd only trusts a key that announced the node on Nostr and has a review.
prover_pathCU_PROVER_BIN, CU_PROVER, PATHWhere cu-prover is.
httpautoauto takes the node's first choice and falls back to HTTP/1.1 for 10 minutes if HTTP/2 fails to open. "2" or "1.1" uses only that. "1.1" turns HTTP/2 off.
maxConnectionDepositMsat100000The largest connection deposit you accept. A node that asks for more is not used.
http2.reuseper-clientWhich requests may share an HTTP/2 connection: per-client (one local API key), shared (everything this daemon sends to that node), or none (a connection per request).
prewarm.enabledtruePrepare a connection ahead while you are sending requests. An unused one costs its deposit. It only pre-warms nodes whose deposit is at most prewarm.maxDepositMsat (5000).

More keys (http2.maxConnections, prewarm.maxTotal, …) are in routstrd's docs/confidential-upstream.md.

Run and send a request

routstrd start                                            # restart it after editing config.json
# optional: pin every request to one node
routstrd start --provider https://routstr.cypherpunk.today

curl -i http://127.0.0.1:8008/v1/chat/completions \
  -H 'content-type: application/json' \
  -H 'x-routstr-verify: confidential' \
  -d '{"model":"deepseek-v4-flash","max_tokens":200,"stream":true,
       "messages":[{"role":"user","content":"Say hi in three words."}]}'

Pick a model the node lists in its offer (https://routstr.cypherpunk.today/v1/confidential/offer?v=3, the price_list keys). max_tokens is required. With the header, a request is either confidential or refused; it never goes out in the clear. Only POST /v1/chat/completions and the image endpoints (/v1/images/generations, /v1/image/generate) work this way. With the OpenAI Python client: default_headers={"x-routstr-verify": "confidential"}.

The response headers tell you what happened:

A stream sends its final data: [DONE] only after the receipt and proofs check out. If a check fails, the stream ends with an error and no [DONE].

Reuse scopes

Requests on one HTTP/2 connection are linkable for the provider. With the default per-client, everything sent with one local API key can be linked that way, across conversations. To narrow it:

Logs

routstrd logs -n 50                                  # today's log
routstrd logs -f                                     # follow
grep '\[confidential\]' ~/.routstrd/logs/$(date +%F).log

Each confidential request writes one line, [confidential] request {...}, with the framing, what it found (cold, armed or open connection), time to first token and bytes each way. It holds no content or credentials. Other lines to look for:

Set ROUTSTRD_LOG_LEVEL=debug before routstrd start for more detail. routstrd never writes confidential prompts or answers to its request/response log.

4. Developers

4a. The browser library

routstr-confidential-web is what the web app uses. The cryptography runs as WebAssembly in a Web Worker. The built wasm is committed, so you do not need Rust. Install it from git and pin a commit:

npm install github:jooray/routstr-confidential-web#<commit on main>

npm builds dist/ on install. In Vite, set worker: { format: "es" } and optimizeDeps: { exclude: ["@routstr/confidential-web"] }. Your page's CSP needs script-src 'self' 'wasm-unsafe-eval', worker-src 'self', and connect-src for the node (https: and wss:). The web app's vite.config.ts is a working example.

import { createConfidentialClient } from "@routstr/confidential-web";

const client = createConfidentialClient({
  node: "https://routstr.cypherpunk.today",
  nodePubkeys: ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
  trustedHosts: ["api.venice.ai"],
  credential: "sk-…",                 // or client.setCredential(...) later
  http: "auto",                       // "2", or "1.1" (HTTP/2 off); client.setHttp(...) later
  prewarm: true,
  onEvent: (e) => console.debug(e),   // progress events, never secrets
});

const stream = await client.chat.completions.create({
  model: "deepseek-v4-flash",
  messages: [{ role: "user", content: "Hello" }],
  max_tokens: 512,
  stream: true,
});
for await (const chunk of stream) out.textContent += chunk.choices[0]?.delta?.content ?? "";
const v = await stream.verification;  // throws if any proof or receipt check failed
console.log(v.framing, v.n, v.connection, v.deposit, v.ttftMs, v.receipt.cost_msats);

The main options:

OptionDefaultMeaning
http"auto"The node's first framing, with a fallback from HTTP/2 to HTTP/1.1. "2" or "1.1" forces one.
reuseScopeone scope per credentialRequests with different scopes never share a connection, so the provider cannot link them that way.
http2.reuse"shared""none" opens a connection per request.
maxConnectionDepositMsat100000The largest connection deposit accepted. More is refused before anything is sent.
padBucketoffPad the request body to a multiple of N bytes, or "pow2". It hides the exact size from network observers and the provider, not from the node, which learns the token count at settlement. It costs upload.
prewarmofftrue or options (maxDepositMsat, default 5000; allowMetered). Keeps one prepared connection while the page is visible and funded.
onNodeProtocolOutdatedCalled when the node's offer names an older protocol.

What stream.verification resolves to (the main fields):

FieldMeaning
sid, nThe connection id and the request's number on it.
framing, stream, fellBack"2" or "1.1"; the HTTP/2 stream; whether HTTP/2 failed and it fell back.
connection"new", "armed" or "reused".
receipt, receiptJson, receiptSigThe node-signed receipt.
receiptSigOk, attestationSigOk, usageMatch, costWithinOfferThe checks. All are true, or the call would have thrown.
costMsats, ownUsageWhat you paid, and the usage the client decrypted itself.
depositThe connection deposit and whether this request released it.
providerThe certificate chain, TLS version, cipher suite and ALPN.
http, ttftMs, timingsThe provider's status, the time to first token, and per-step timings and proof results.

Typed errors to handle: NodeProtocolOutdatedError (a v2 node), UpgradeRequiredError (the node is newer), TwoPcVersionMismatchError, ConnectionDepositError, RequestRefusedError (402 balance, 409 offer changed). Images: client.images.generate({...}). The library README has the rest, including the security model of serving this code from a web page.

4b. The SDK (Node and Bun)

The routstr SDK has the same client for Node and Bun. It runs cu-prover and cu-2pc as child processes. Build it from the confidential-2pc branch as in section 3, then depend on your local checkout (for example npm install /path/to/routstr-sdk).

A single request, without routing:

import { fetchConfidential } from "@routstr/sdk/bun";   // or "@routstr/sdk/node"

const res = await fetchConfidential({
  baseUrl: "https://routstr.cypherpunk.today",
  bearer: "sk-…",
  body: { model: "deepseek-v4-flash", max_tokens: 200, stream: true,
          messages: [{ role: "user", content: "Hello" }] },
  options: {
    trustedHosts: ["api.venice.ai"],
    nodePubkeys: ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
    proverPath: "/path/to/cu-prover",   // cu-2pc next to it, or twoPcPath / CU_2PC
    http: "auto",
  },
  reuseScope: "conversation-1",          // optional
});
// read res.body as a normal SSE stream, then:
const { costMsats } = await (res as any).confidentialSettled;
console.log(res.headers.get("x-routstr-confidential-http"), res.headers.get("x-routstr-confidential-connection"));

With routing, node selection and top-ups, pass the same options as confidential: {...} to routeRequests (from @routstr/sdk/bun or /node); RoutstrClient then runs fetchConfidential in place of fetch(). Details: client/confidential/README.md in the SDK. To test against a node you run: CU_PROVER=… CU_2PC=… bun scripts/confidential-v3-e2e.ts <node> <sk-key> <model>.

5. Node operators

A confidential node is a normal Routstr node plus cu-sidecar, a small Rust service. The sidecar holds the provider key, relays the TLS connection to the provider, writes the one record that carries the key, and verifies the client's proofs. Billing uses the node's normal reservation and settlement.

What you need

1. Check your provider

Confidential mode needs a provider that behaves in specific ways (it waits for the full body, uses the last duplicate JSON key, enforces token caps, reports usage in the stream, and more). Check yours with the offer file you will deploy. The key goes only to the offer's upstream_host.

cd routstr-confidential
T=crypto/tools/provider-profile-check.py
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --model <a cheap chat model>          # A1-A13: all must pass
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --model <same> --h2                  # H1-H8: before you offer HTTP/2
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --image-model <model> --images       # I1-I9: before you offer images

The --h2 run idles a connection for h2.idle_secs (5 minutes by default) and ends with a verdict: offer "2": yes, no, because … (keep HTTP/1.1 only), or not yet (run it again). The image run generates three paid images.

2. The sidecar offer file

Start from crypto/relay/offer.venice.json. It has the host, the request-head template, the chat block (the pinned suffix), the HTTP framings and the image endpoint. The file carries no prices: models and prices come from the node.

"http": ["2", "1.1"],
"h2": {"probe_path": "/api/v1/models", "max_requests": 100, "max_lifetime_s": 3600,
       "idle_secs": 300, "kinds": ["chat", "images"]}
FieldDefaultMeaning
http["1.1"]Framings in your order of preference. Add "2" only after --h2 said yes. Keep "1.1" after it: clients fall back to it.
h2.probe_pathA cheap authenticated GET (the models list). It runs once per connection and stores the key in the provider's header table.
h2.max_requests100Requests per connection (1 to 1000).
h2.max_lifetime_s3600A connection closes at the first settlement after this long.
h2.idle_secs300The sidecar's idle limit. Not longer than the idle time your --h2 run passed with.
h2.kinds["chat"]Add "images" to serve images on HTTP/2 (needs endpoints.images).
endpoints.imagesThe image endpoint (Venice: POST /api/v1/image/generate, count_key "variants"). Without it the node offers chat only.
model_allowlistempty (all)Restrict the confidential models.

For another provider, see docs/SETUP.md and docs/CHAT-SUFFIX.md in routstr-confidential. The sidecar refuses at startup a chat block that clients would refuse.

3. Build and run the sidecar

# container
podman build -f crypto/Containerfile.sidecar -t cu-sidecar crypto      # or docker
openssl rand -hex 32     # the shared token: CU_SIDECAR_TOKEN here, CONFIDENTIAL_SIDECAR_TOKEN on the node
podman run -d --name cu-sidecar --network <node network> \
  -e UPSTREAM_API_KEY=… -e CU_SIDECAR_TOKEN=… \
  -v ./my-offer.json:/etc/cu-sidecar/offer.json:ro \
  cu-sidecar \
  --listen 0.0.0.0:7444 --upstream api.venice.ai:443 --key-env UPSTREAM_API_KEY \
  --offer /etc/cu-sidecar/offer.json --node-settles --runs-dir /runs

# or with cargo (binary in crypto/zkbench/target/release/cu-sidecar)
cd crypto && cargo build --release -p cu-sidecar

4. The node

Build routstr-core from confidential-2pc (its Dockerfile is the upstream one, or uv sync for a source checkout) and set:

CONFIDENTIAL_SIDECAR_URL=http://cu-sidecar:7444
CONFIDENTIAL_SIDECAR_TOKEN=<the sidecar's CU_SIDECAR_TOKEN>
CORS_ORIGINS=https://jooray.github.io,https://your-app.example     # comma-separated, not JSON

Start it with a long WebSocket ping timeout and one worker:

uvicorn routstr:fastapi_app --host 0.0.0.0 --port 8000 --proxy-headers \
  --ws-ping-interval 20 --ws-ping-timeout 300
SettingDefaultEffect
CONFIDENTIAL_CONNECTION_DEPOSIT_MSATmax(abandon fee, session fee, 1000)Held when a connection opens, released by its first settled request, kept when the client wastes the connection. 0 = no deposit.
CONFIDENTIAL_IDLE_TIMEOUT_S120Close a connection with no request in flight after this long. At least 10, at most h2.idle_secs.
CONFIDENTIAL_MAX_ACTIVE_PER_IP16Connections per client address. At the limit the longest-idle used one is closed, otherwise the setup gets 429.
CONFIDENTIAL_MAX_UNUSED_PER_IP12Unused connections per client address (prepared ones count).
CONFIDENTIAL_TRUSTED_PROXIESnonePeers whose client-address header is believed, e.g. 127.0.0.1,::1 for nginx on the same host. Without it, all clients behind your proxy share one limit.
CONFIDENTIAL_SESSION_FEE_MSAT0A flat fee, once per connection, with its first settled request.
CONFIDENTIAL_ABANDON_FEE_MSATthe session feeFor a request dropped before it reached the provider, and for most provider 4xx refusals.
CONFIDENTIAL_RATE_SURCHARGE_PCT0Added to the per-token rates and image prices.
CONFIDENTIAL_RESERVE_MARGIN_TOKENS16Completion tokens reserved beyond max_tokens.

Each connection costs the node about 18 MB of upload and under 0.5 s of CPU, before any request. Size the deposit to that. All of these values go into the signed offer, so clients see them before they pay.

5. Check and test

curl -s 'https://<node>/v1/confidential/offer?v=3' | jq '{v, http, h2, twopc,
  connection_deposit_msat, idle_timeout_s, upstream_host, models: (.price_list|keys)}'

v must be 3, twopc "split-public-1", http what your offer file lists, and models not empty.

The reference driver (in crypto/client, after npm install there; it needs cu-prover and cu-2pc):

cd crypto/client && npm install
CU_PROVER=../zkbench/target/release/cu-prover CU_2PC=../zkbench/target/release/cu-2pc \
  node --experimental-strip-types src/driver.ts --node https://<node> --node-pubkey <hex or npub> \
  --model <model> --max-tokens 40 --http 2 --requests 3 --auth <sk-… or cashu…>
# then the same with --http 1.1

It prints every check. The gates run against your provider key and spend a little money:

ScriptWhat it checks
VENICE_API_KEY=… crypto/tools/v3-check.shStarts its own sidecar on loopback. Honest connections on both framings and attacks by a malicious client.
VENICE_API_KEY=… crypto/tools/v3-node-check.shStarts a sidecar and a routstr-core node on scratch state (set CU_CORE to your checkout, with its .venv). Checks the money outcome of every case in the node's database.

Both exit non-zero on any deviation. CU_KEY_ENV, CU_UPSTREAM and CU_V3_OFFER point them at another provider. CU_NO_IMAGES=1 skips the paid image cases.

With the web app: open the Node panel, enter your node URL, its key and the provider host, press Apply, fund and send a message. If the app cannot reach the node, check CORS_ORIGINS first.

6. Announcing, or not

A node with a sidecar configured adds the tag ["confidential","3"] to its Nostr listing, so clients can find confidential nodes. A test node should not announce itself. Point RELAYS at a relay that does not exist (for example RELAYS=ws://127.0.0.1:9), and stop test nodes when you are done. A forgotten node with real relays keeps announcing itself.

Upgrading from v2: upgrade the sidecar and the node together. v2 clients then get 426 upgrade_required. See "Upgrading from v2" in docs/SETUP.md.

7. Providers

Which upstream providers work with protocol v3, measured on 2026-11-12 with free models only (Venice: the earlier reference measurement; Cloudflare and OpenCode Zen end to end on 2026-11-13). Results change when providers change their front ends: re-run before you rely on a row. Details, evidence and the image analysis for OpenRouter: docs/PROVIDERS.md.

ProviderWorks (chat)HTTP/2HTTP/1.1Idle HTTP/2 kept forChatImagesEnd to end (sidecar)Through a node
Venicereference✓✓✓400 s✓✓✓✓
Numes✓✓✓600 s✓?✓✓
xKiro✓pin price fields✓✓unknown fields accepted400 s✓?✓✓
OpenRouter✓pin price fields✓✓400 s✓✗only via chat✓✓
Cloudflare Workers AI✓pin price fields✓✓unknown fields accepted400 s✓?✓✓
NVIDIA NIM✗✓✗first model wins340 sTCP close✗?✗decoy runs–
Google AI Studio✗✗usage in content✗usage in content240 s✗?––
UnoRouter✗✗usage not last✗cap overrun?✗?––
OpenCode Go✗✗no HTTP/2✗needs extra header–✗?––
OpenCode Zen?free quota never served✗no HTTP/2?429 on every chat–???only 429s–

✓ works, ✗ does not, ? not tested or not available, – not run or not applicable. "Pin price fields": the provider honours request fields the protocol does not fix, so the operator pins the price-changing ones off in the offer (what that means). "Idle HTTP/2 kept for" is how long the provider keeps a silent HTTP/2 connection open before it closes it (with GOAWAY unless noted); the idle_secs to offer is in each provider's notes. "End to end" means our sidecar and the reference driver (receipts signed by the sidecar). "Through a node" means a scratch routstr-core node: its signed offer, its receipts, and the money of every case checked in its database. The reasons behind each mark are below.

What "pin price fields" means

The client's part of the request body is private: the node never sees it, and the provider reads it before the pinned suffix. A field the provider honours there that changes the price without changing the pinned model, the caps or the usage token counts is a loss for the node, because the node bills the reported tokens at the offer's rates. The operator therefore lists such fields in the offer's chat.fixed_pins with an "off" value (false, "off", [], or an object whose members are all off). The suffix comes last and the provider keeps the last value of a duplicate key, so the pin wins. This is a setting of the sidecar's offer file, not a protocol change. Which fields need it is to verify per provider.

The key-echo check (H7)

The client decrypts every response the provider sends. If the provider ever copied the node's API key into a response, for example an error that quotes the request's headers or says "invalid key sk-…", the client would learn the key. Before framing "2" is offered, the HTTP/2 check (item H7 of provider-profile-check.py) sends one request of each kind and searches every response, headers and body, for the key:

The check uses an unknown field for the last one. Providers that accept any field need a field with a bad value instead: Numes a bad service_tier, OpenRouter and Cloudflare "top_p":2, xKiro a messages that is not an array (it accepts every other bad value tried). With those, Venice, Numes, xKiro, OpenRouter and Cloudflare pass H7 and every other HTTP/2 item at their suggested idle_secs.

Venice (reference) ✓ works

Endpoint
api.venice.ai/api/v1, the deployed provider. Its model ids carry no org/ prefix, so its offer has no upstream_models and is unchanged.
Profile
Every HTTP/1.1 item (A1-A13) and every HTTP/2 item (H1-H8) passes.
Idle, keepalive
A silent HTTP/2 connection closes after 400 s with GOAWAY. A PING or an empty SETTINGS every 2-5 min holds it 16+ min. Suggested idle_secs: 390 (production).
Images
✓ native /api/v1/image/generate.
End to end
Sidecar (v3 gates, production): chat ✓ on HTTP/2 and HTTP/1.1, a follow-up on a kept connection ✓, an image ✓, chat and an image on one connection ✓.
Through a node
Chat ✓ on both framings, follow-up ✓, every receipt and the money exact: v3-node-check.sh 63/63 with the new decoy cases, routstrd 11/11.

Numes ✓ works

Endpoint
api.numes.ai/api/v1
Model
Free moonshotai/kimi-k3.1-pro, offered by the node as kimi-k3.1-pro (15 models in the node's offer, all via upstream_models).
HTTP/1.1
The profile holds (A1, A2, A5, A6, A7, A9, A11, A12). An unknown field gets a 502: passed on and refused upstream, not ignored (A4). Of the price fields, service_tier and web_search_options get a 400, prediction is accepted (A10).
HTTP/2
Every item passes (H1-H8) at 590 s. The error-path probe (H7) uses a bad service_tier, since an unknown field is a 502.
Idle, keepalive
600 s, GOAWAY. PINGs do not reset it; a request every 200 s keeps it (an idle limit, not an age limit). Suggested idle_secs: 590.
Images
? no image models.
End to end
Sidecar: chat ✓ on both framings, three requests on one connection and a request after 60 s idle ✓. Node: chat ✓ on both framings, three on one connection ✓, money exact; the decoy ran the pinned model and the model swap was refused on both framings.
Watch
Slow at times (time to first token up to 69 s): one h2-three run hit the 180 s case limit and passed with 600 s.

xKiro ✓ works pin price fields

Endpoint
api.xkiro.com/v1 (FreeLLMAPI key)
Model
Node: mistral-small-2603 → mistralai/mistral-small-2603. The node prices 44 of xKiro's 69 models; ministral-3b is not among them, so the sidecar-level model could not be used.
HTTP/1.1
The profile holds (A1, A2, A5, A6, A7, A9, A11, A12), but unknown fields (A4) and price fields (A10) are accepted silently; even "temperature":"hot" is a 200.
HTTP/2
Every item passes (H1-H8) at 390 s. The key-echo check (H7) uses "messages":"x" as the refused field (why): no other bad value tried gets a 4xx.
Idle, keepalive
400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested idle_secs: 390.
Pin
The same rule as OpenRouter (what pin means). Since xKiro accepts any field without complaint, which ones it honours is to verify per field before a paid account is offered.
Images
? no image models.
End to end
Sidecar: chat ✓ on both framings, three on one connection and after 60 s idle ✓. Node: chat ✓ on both framings, three on one connection ✓, money exact, decoy and model swap as Numes. Also through routstrd (gate 10/10, five on one connection) and the SDK gate (20 requests, all cases).

OpenRouter ✓ chat pin price fields not yet safe to offer: PDF OCR, provider price spread ✗ images

Endpoint
openrouter.ai/api/v1 (FreeLLMAPI key, account with credit)
Model
Sidecar: liquid/lfm-2.5-2.6b:free. Node: routstr's OpenRouter catalogue drops :free ids, so a node offers paid models only; tested with mistral-nemo → mistralai/mistral-nemo ($0.00000024 a request).
HTTP/1.1
The profile holds (A1, A2, A9, A11). The usage event (A7) passes under the §5.4 rule: one choice with an empty delta, finish_reason, native_finish_reason (it failed before the fix). The SSE framing (A12) passes on the re-run: the : OPENROUTER PROCESSING comment lines come first and are no part of the usage tail. Unknown fields are accepted (A4).
HTTP/2
Every item passes (H1-H8) at 390 s. The key-echo check (H7) uses "top_p":2 as the refused field (why), since an unknown field is accepted.
Idle, keepalive
400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested idle_secs: 390.
Pin
Verified live on 2026-11-13 (docs/providers/OPENROUTER.md). The suggested offer crypto/relay/offer.openrouter.json pins plugins, models, tools, provider, preset, web_search_options and service_tier off, and the node leaves out models with non-text output, tiered prices, 1-hour cache writes, audio input and native web search. With that, every price attack tested cost OpenRouter no more than the node charged. Two gaps remain: a PDF in the request triggers paid OCR that no allowed pin switches off, and OpenRouter may route an honest request to a provider 2-7x dearer than the listed rate. Both need an account setting or a wider pin policy before OpenRouter is offered. Pinning tools off also removes function calling.
Images
✗ images come through chat (modalities); not supported (see docs/PROVIDERS.md).
End to end
Sidecar with the :free model: chat ✓ on both framings (one, three on a connection, decoy, model swap), follow-up ✓; image ✗, chat and image on one connection ✗. Node: chat ✓ on both framings, three on one connection ✓, money exact, decoy and model swap as Numes. This account's data policy allows no endpoint of liquid/lfm-2.5-2.6b (a 404, settled as an error head with the abandon fee, exactly).

Cloudflare Workers AI ✓ works pin price fields; the offer publishes the account id

Endpoint
api.cloudflare.com/client/v4/accounts/<account>/ai/v1 (FreeLLMAPI key)
Model
Free tier @cf/meta/llama-3.2-1b-instruct; the node offers it as meta/llama-3.2-1b-instruct (pinned as @cf/meta/llama-3.2-1b-instruct via upstream_models).
HTTP/1.1
The profile holds (A1, A2, A5, A6, A9, A11, A12). The usage event (A7) passes under the §5.4 rule: the usage is on every chunk and the last one has an empty delta and a finish_reason; only the last event is disclosed (it failed before the fix). Unknown fields (A4) and price fields (A10) are accepted.
HTTP/2
Every item passes (H1-H8) at 390 s, measured with the probe /client/v4/user/tokens/verify. The key-echo check (H7) uses "top_p":2 as the refused field (why), since an unknown field is accepted. The clients accept only an API base plus /models as the probe, so the end-to-end offer uses …/accounts/<account>/ai/v1/models: a 405 without the key in it, and its answer is discarded (the probe only carries the credential into the HPACK table).
Idle, keepalive
400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested idle_secs: 390.
Images
? image models only on /ai/run/@cf/...; /ai/v1/images/generations answers "No route for that URI".
End to end
Sidecar (2026-11-13, 17/17): one request on each framing, three on one HTTP/2 connection, two HTTP/1.1 connections, a 200 kB body on both, a second request after 60 s idle, armed connections on both, a refused request then the good one on both, the decoy on both (the provider ran and reported the pinned model), the suffix and model-swap attacks refused on both. Node (8/8): chat ✓ on both framings, three on one connection, two HTTP/1.1 connections, the decoy on both, the model swap refused on both; every receipt verified and the money exact.
Node setup
Workers AI has no /models listing (405), so the node's generic provider finds no models: the model is added through the node's admin API (POST /admin/api/upstream-providers/{id}/models, priced by hand).
Watch
The account id is in the chat path, so the head template and the probe path of the signed offer publish it. Pin: the same rule as OpenRouter (what pin means); which Workers AI fields change the price is to verify.

NVIDIA NIM ✗ model binding broken

Endpoint
integrate.api.nvidia.com/v1 (FreeLLMAPI key)
HTTP/1.1
✗ the first model key wins (routing), max_tokens the last (profile item A2). So a client can name another model in the private part and be billed at the pinned model's rates; configuration cannot fix it. The rest holds (A1, A4 with a 400, A5, A6, A7, A9, A11, A12); price fields are accepted (A10).
HTTP/2
Every item passes (H1-H8) at 330 s.
Idle, keepalive
340 s, a TCP close without GOAWAY. A PING every 120 s holds it 15 min. Suggested idle_secs: 330.
Images
? image models are on ai.api.nvidia.com/v1/genai/..., another host and route.
End to end
Sidecar: chat ✓ on both framings and three on one connection ✓, but the decoy ✗ on both framings (the request went to the decoy model and did not finish in 180 s). Node: not run, blocked by the model binding.

Google AI Studio ✗ usage event shape

Endpoint
generativelanguage.googleapis.com/v1beta/openai (FreeLLMAPI key)
HTTP/1.1
✗ the usage is merged into the last content chunk (A7); disclosing it would disclose output, so it is not supported. Only one cap key is accepted: max_tokens with max_completion_tokens is a 400 (A6); completion tokens are 0 at finish_reason: length (thinking is not counted). The rest holds (A1, A2, A4 with a 400, A5, A9, A11, A12).
HTTP/2
✗ a request without a key gets a 400, not a 401 (H5), and the usage is not its own event (H8). H1-H4 and H6 pass.
Idle, keepalive
240 s, GOAWAY session_timed_out. PINGs do not reset it; a request every 150 s keeps it. Suggested idle_secs: 230.
Images
? no free image model.
End to end
Not run (blocked).
Key
This key has a ., outside the provable alphabet (the sidecar accepts only [A-Za-z0-9_-], the π_NK alphabet). Classic AIza… keys fit.

UnoRouter ✗ cap overrun, usage not last

Endpoint
api.unorouter.com/v1 (FreeLLMAPI key). Free models: 1 request/min per model.
HTTP/1.1
✗ 64 completion tokens at a cap of 20 (A6). Duplicate keys are refused in both orders (A2); an incomplete answer (A11). An intermittent 403 consent_required; : PING comments; the model is reported without :free.
HTTP/2
✗ after 173 s idle the last event before [DONE] was not the usage event (H8; H5 fails with it). H1-H4 and H6 pass.
Idle, keepalive
? not measured (1 request/min).
Images
? free FLUX models on /v1/images/generations, but the account's image budget answered 429.
End to end
Not run (blocked).

OpenCode Go ✗ needs a session header

Endpoint
opencode.ai/zen/go/v1, a flat-rate subscription; tested with glm-5.3-flash.
HTTP/1.1
✗ every request needs an x-opencode-session header (400 MissingSessionID); the head template allows only Host, Authorization, Content-Type, Accept-Encoding, Connection and Content-Length. With the header added the profile holds (A1, A2, A6, A7, A9, A11, A12); unknown and price fields are accepted (A4, A10).
HTTP/2
✗ no h2 in ALPN (H1).
End to end
Not run (blocked). What carrying the header would take is in docs/PROVIDERS.md.

OpenCode Zen ? untested: the free quota is never available to this key

Endpoint
opencode.ai/zen/v1 (OpenCode Go key). 47 models, 11 free (/zen/v1/models), for example deepseek-v4.1-flash-free.
Quota
Every chat request is a 429 FreeUsageLimitError ("Free models work in OpenCode; subscribe to OpenCode Go to keep going"), for four free models, with or without x-opencode-session. Tried 2026-11-12 ~20:30 UTC (retry-after 12 655 s), 23:36 UTC (retry-after 1405 s, counting down to midnight) and 2026-11-13 00:00:35 UTC: still a 429, now with retry-after 86 364 s. The free models appear to be meant for the OpenCode client only. Paid models need a balance (402).
HTTP/1.1
The TLS profile passes (A9); every other item is inconclusive on the 429.
HTTP/2
✗ no h2 in ALPN (H1): HTTP/1.1 only (an offer with http: ["1.1"] and no h2 block).
End to end
Sidecar (2026-11-13, HTTP/1.1): the protocol runs against Zen (handshake, credential head, body, proofs); the 429 head was disclosed through π_C3S and settled as an error, the receipt verified; the model-swap attack was refused. Chat itself (usage event, decoy, caps) is untested; no node run.
Header
Unlike Go, Zen does not ask for x-opencode-session (an unknown model without it is a 400 "Model is unavailable", not MissingSessionID).

How it was measured

The checks of provider-profile-check.py are numbered A1-A13 (HTTP/1.1) and H1-H8 (HTTP/2); the provider notes above name them next to what they mean, for operators who re-run the check. Each provider got its own offer file and ran the HTTP/1.1 profile items (A1, A2, A4-A7, A9-A12; A3 is Venice-only, A8 and A13 were not run), the HTTP/2 items H1-H8 at the suggested idle_secs, the idle close of one silent HTTP/2 connection and whether a PING every 120 s holds it 15 minutes. The profile check uses the client's exact TLS profile (TLS 1.3, one suite, P-256, a KeyUpdate after the head). Providers whose chat profile holds then ran end to end through our sidecar with the reference driver (crypto/tools/v3-check.sh: both framings, three requests on one connection, a request after 60 s idle, a 200 kB body, a decoy model, the model-swap attack), and then through a scratch routstr-core node (crypto/tools/v3-node-check.sh: both framings, three requests on one connection, the decoy, the model swap on both framings, every receipt checked and the money of each case exact in the node's database).

A node offers a model under its public id (without the org/ prefix these providers use) and pins the provider's own id in the request; the signed offer lists the pairs in upstream_models. Images: no provider here has a free image model on a route the sidecar describes.

To re-run for your provider (the key goes only to the offer's upstream_host):

T=crypto/tools/provider-profile-check.py
python3 -I $T --offer my-offer.json --key-env MY_KEY --model <model>
python3 -I $T --offer my-offer.json --key-env MY_KEY --model <model> --h2 --h2-no-images --h2-idle <idle_secs>
CU_V3_OFFER=my-offer.json CU_KEY_ENV=MY_KEY CU_UPSTREAM=<host>:443 CU_TRUSTED_HOSTS=<host> CU_MODEL=<model> \
  CU_SWAP_MODEL=<other model> CU_NO_IMAGES=1 CU_CASES="h2-one h11-one h2-three h2-decoy h11-decoy" crypto/tools/v3-check.sh

6. Branch map

Nothing here is merged upstream. The pull requests below are open against Routstr. routstr.cypherpunk.today runs the v3 branches below.

ComponentRepositoryBranch
protocol, sidecar, reference client, paperjooray/routstr-confidentialmain (v3); v2 archived
nodejooray/routstr-coreconfidential-2pc (v3; Routstr/routstr-core#855); confidential-v2 archived
SDKjooray/routstr-sdkconfidential-2pc (v3; Routstr/routstr-sdk#112); confidential-v2
routstrdjooray/routstrdconfidential-upstream (v3; Routstr/routstrd#155); confidential-v2
browser libraryjooray/routstr-confidential-webmain (v3); v2
web appjooray/routstr-confidential-pwamain (v3); v2

Every component must be on v3. There is no fallback between versions: a v3 node answers a v2 client with upgrade_required, and a v3 client reports node_protocol_outdated for a v2 node and does not use it.

7. Reporting problems

Open an issue at github.com/jooray/routstr-confidential/issues. Include:

Never post your sk- key, Cashu tokens, provider API keys, node nsecs, the sidecar token, or your prompts. The receipt JSON is safe to share. It holds usage and costs, no content.