Skip to content

Commit 2db0337

Browse files
authored
feat(operator): external backend connect-mode + self-advertised payment config (#10)
Lets the operator run against any OpenAI-compatible upstream (cli-bridge, llama.cpp, …) with no GPU, and makes its payment surface self-describing so clients configure themselves with zero hardcoded addresses. - config: `vllm.external` flag. When set, InferenceServer::start connects to an already-running server at host:port via VllmProcess::connect instead of spawning vLLM, and the watchdog no longer tries to respawn a backend it doesn't manage. - server: /v1/operator now advertises `shielded_credits` + `chain_id`, so a client can build the ShieldedCredits EIP-712 domain from operator info alone. - sdk/scripts: end-to-end proofs driving the viem SDK — - onchain-e2e.mjs: signs a SpendAuth and submits authorizeSpend against a live ShieldedCredits, asserting the contract's ECDSA.recover accepts the SDK signature (balance debited, nonce incremented). - local-onchain-proof.sh: deploys ShieldedCredits + a mock token, funds a credit account, runs onchain-e2e. - http-e2e.mjs: full path — SDK signs → operator-lite validates (recover + on-chain getAccount) → proxies to the backend → completion; verifies the on-chain spend nonce advances. Verified locally: anvil + ShieldedCredits + operator-lite + cli-bridge (claude-code/sonnet) — SpendAuth-gated chat returns a real completion, 402 without payment, settlement advances the on-chain nonce.
1 parent 1365327 commit 2db0337

7 files changed

Lines changed: 359 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ contracts/dependencies/
1212
.DS_Store
1313
.tangle/
1414
.foreman/
15+
16+
# operator runtime nonce store
17+
data/

operator/src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ pub struct VllmConfig {
7878
/// Startup timeout in seconds.
7979
#[serde(default = "default_startup_timeout")]
8080
pub startup_timeout_secs: u64,
81+
82+
/// When true, connect to an already-running OpenAI-compatible server at
83+
/// `host:port` instead of spawning a vLLM subprocess. Lets the operator
84+
/// run against cli-bridge / llama.cpp / any OpenAI-compatible backend with
85+
/// no GPU — used for local end-to-end testing.
86+
#[serde(default)]
87+
pub external: bool,
8188
}
8289

8390
#[derive(Debug, Clone, Serialize, Deserialize)]

operator/src/lib.rs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,36 @@ impl BackgroundService for InferenceServer {
212212
let config = self.config.clone();
213213

214214
tokio::spawn(async move {
215-
// 1. Start the vLLM subprocess
216-
let vllm_handle = match VllmProcess::spawn(config.clone()).await {
217-
Ok(h) => Arc::new(h),
218-
Err(e) => {
219-
tracing::error!(error = %e, "failed to spawn vLLM");
220-
let _ = tx.send(Err(RunnerError::Other(e.to_string().into())));
221-
return;
215+
// 1. Start the inference backend: either spawn a vLLM subprocess,
216+
// or (when configured external) connect to an already-running
217+
// OpenAI-compatible server at host:port (cli-bridge, llama.cpp,
218+
// …) — the latter needs no GPU and is used for local e2e.
219+
let vllm_handle = if config.vllm.external {
220+
tracing::info!(
221+
host = %config.vllm.host,
222+
port = config.vllm.port,
223+
"connecting to external OpenAI-compatible backend (no subprocess)"
224+
);
225+
match VllmProcess::connect(config.clone()) {
226+
Ok(h) => Arc::new(h),
227+
Err(e) => {
228+
tracing::error!(error = %e, "failed to connect to external backend");
229+
let _ = tx.send(Err(RunnerError::Other(e.to_string().into())));
230+
return;
231+
}
232+
}
233+
} else {
234+
match VllmProcess::spawn(config.clone()).await {
235+
Ok(h) => Arc::new(h),
236+
Err(e) => {
237+
tracing::error!(error = %e, "failed to spawn vLLM");
238+
let _ = tx.send(Err(RunnerError::Other(e.to_string().into())));
239+
return;
240+
}
222241
}
223242
};
224243

225-
tracing::info!("vLLM process started, waiting for readiness");
244+
tracing::info!("inference backend started, waiting for readiness");
226245
if let Err(e) = vllm_handle.wait_ready().await {
227246
tracing::error!(error = %e, "vLLM failed to become ready");
228247
let _ = tx.send(Err(RunnerError::Other(e.to_string().into())));
@@ -310,6 +329,15 @@ impl BackgroundService for InferenceServer {
310329
}
311330

312331
if !vllm_handle.is_healthy().await {
332+
// An external backend isn't managed by the operator, so we
333+
// can't respawn it — just warn and keep serving (it may
334+
// recover on its own).
335+
if config.vllm.external {
336+
tracing::warn!(
337+
"external inference backend health check failed — not operator-managed, will retry"
338+
);
339+
continue;
340+
}
313341
tracing::error!("vLLM health check failed — attempting respawn");
314342

315343
// Shut down the old process

operator/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,10 @@ async fn operator_info(State(state): State<AppState>) -> Json<serde_json::Value>
679679
},
680680
"billing_required": state.billing_config.billing_required,
681681
"payment_token": state.billing_config.payment_token_address,
682+
// Payment surface, so clients can self-configure the ShieldedCredits
683+
// EIP-712 domain (verifyingContract + chainId) with no hardcoding.
684+
"shielded_credits": state.tangle_config.shielded_credits,
685+
"chain_id": state.tangle_config.chain_id,
682686
}))
683687
}
684688

sdk/scripts/http-e2e.mjs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Full-path HTTP e2e: the viem SDK signs a SpendAuth and calls a running
2+
// operator (operator-lite) which validates it (EIP-712 recover + on-chain
3+
// getAccount balance/key check) and proxies the completion to its backend
4+
// (cli-bridge → a real local coding harness). Proves the entire billed
5+
// inference path end to end. Reads ../../.env.local; operator at OPERATOR_API.
6+
//
7+
// OPERATOR_API=http://127.0.0.1:9100 node sdk/scripts/http-e2e.mjs
8+
9+
import { readFileSync } from 'node:fs'
10+
import { fileURLToPath } from 'node:url'
11+
import { dirname, resolve } from 'node:path'
12+
import { createPublicClient, http, getAddress } from 'viem'
13+
14+
import {
15+
createInferenceClient,
16+
createLocalSpendSigner,
17+
} from '../dist/index.js'
18+
19+
const here = dirname(fileURLToPath(import.meta.url))
20+
const env = Object.fromEntries(
21+
readFileSync(resolve(here, '../../.env.local'), 'utf8')
22+
.split('\n')
23+
.map((l) => l.match(/^([A-Z0-9_]+)=(.*)$/))
24+
.filter(Boolean)
25+
.map((m) => [m[1], m[2].trim()]),
26+
)
27+
28+
const OPERATOR_API = process.env.OPERATOR_API ?? 'http://127.0.0.1:9100'
29+
const CHAIN_ID = Number(env.CHAIN_ID ?? '31337')
30+
31+
let failed = 0
32+
const assert = (cond, desc, detail = '') =>
33+
cond
34+
? console.log(` PASS ${desc}`)
35+
: (failed++, console.error(` FAIL ${desc} ${detail}`))
36+
37+
const chain = {
38+
id: CHAIN_ID,
39+
name: 'anvil',
40+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
41+
rpcUrls: { default: { http: [env.RPC_URL] } },
42+
}
43+
const publicClient = createPublicClient({ chain, transport: http(env.RPC_URL) })
44+
45+
const client = createInferenceClient({
46+
operatorUrl: OPERATOR_API,
47+
shieldedCreditsAddress: getAddress(env.SHIELDED_CREDITS),
48+
chainId: CHAIN_ID,
49+
commitment: env.COMMITMENT,
50+
serviceId: 1n,
51+
operatorAddress: getAddress(env.OPERATOR_ADDR),
52+
signer: createLocalSpendSigner(env.USER_KEY),
53+
model: 'claude-code/sonnet',
54+
pricePerInputToken: 1n,
55+
pricePerOutputToken: 2n,
56+
})
57+
58+
async function main() {
59+
console.log(`[http-e2e] operator=${OPERATOR_API} chain=${CHAIN_ID}`)
60+
61+
const nonce = await client.syncNonce(publicClient)
62+
console.log(`[http-e2e] synced spend nonce=${nonce}`)
63+
64+
console.log('[http-e2e] sending SpendAuth-gated chat (real backend, may take a while)…')
65+
const res = await client.chat(
66+
[{ role: 'user', content: 'Reply with exactly one word: hello' }],
67+
{ maxTokens: 64 },
68+
)
69+
70+
const content = res?.choices?.[0]?.message?.content ?? ''
71+
console.log(`[http-e2e] model=${res?.model} content=${JSON.stringify(content)}`)
72+
console.log(`[http-e2e] usage=${JSON.stringify(res?.usage)}`)
73+
74+
assert(typeof content === 'string' && content.length > 0, 'completion has content')
75+
assert((res?.usage?.total_tokens ?? 0) > 0, 'usage reports tokens', `usage=${JSON.stringify(res?.usage)}`)
76+
77+
// The operator consumed the SpendAuth → on-chain nonce advanced.
78+
const after = await client.syncNonce(publicClient)
79+
assert(after === nonce + 1n, 'on-chain spend nonce advanced after the request', `before=${nonce} after=${after}`)
80+
81+
console.log(failed === 0 ? '\n[http-e2e] ALL PASS' : `\n[http-e2e] ${failed} FAILED`)
82+
process.exit(failed === 0 ? 0 : 1)
83+
}
84+
85+
main().catch((err) => {
86+
console.error('[http-e2e] error:', err?.message ?? err)
87+
process.exit(1)
88+
})

sdk/scripts/local-onchain-proof.sh

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env bash
2+
# Deploy the real ShieldedCredits + a mock token against a running anvil, fund a
3+
# credit account, then run the viem SDK on-chain proof (onchain-e2e.mjs).
4+
#
5+
# Prereqs: anvil already running at $RPC_URL (chain 31337); foundry; node.
6+
# GATEWAY_DIR defaults to ~/code/shielded-payment-gateway (home of
7+
# ShieldedCredits.sol + test/MockERC20.sol).
8+
#
9+
# RPC_URL=http://127.0.0.1:8645 bash sdk/scripts/local-onchain-proof.sh
10+
set -uo pipefail
11+
12+
RPC_URL="${RPC_URL:-http://127.0.0.1:8645}"
13+
CHAIN_ID="${CHAIN_ID:-31337}"
14+
GATEWAY_DIR="${GATEWAY_DIR:-$HOME/code/shielded-payment-gateway}"
15+
SDK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16+
ENV_OUT="$(cd "$SDK_DIR/.." && pwd)/.env.local"
17+
18+
# anvil default accounts
19+
DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
20+
OPERATOR_ADDR=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
21+
USER_KEY=0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba
22+
USER_ADDR=0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc # == address(USER_KEY) == spendingKey
23+
FUND_AMOUNT=1000000000000000000000 # 1000 tokens
24+
25+
addr_from() { echo "$1" | grep -i "Deployed to:" | awk '{print $3}'; }
26+
27+
echo "[proof] deploying MockERC20 + ShieldedCredits from $GATEWAY_DIR against $RPC_URL"
28+
cd "$GATEWAY_DIR"
29+
30+
TOKEN=$(addr_from "$(forge create test/MockERC20.sol:MockERC20 \
31+
--rpc-url "$RPC_URL" --private-key "$DEPLOYER_KEY" --root "$GATEWAY_DIR" --broadcast 2>&1)")
32+
[ -n "$TOKEN" ] || { echo "ERROR: MockERC20 deploy failed"; exit 1; }
33+
echo "[proof] token=$TOKEN"
34+
35+
CREDITS=$(addr_from "$(forge create src/shielded/ShieldedCredits.sol:ShieldedCredits \
36+
--rpc-url "$RPC_URL" --private-key "$DEPLOYER_KEY" --root "$GATEWAY_DIR" --broadcast 2>&1)")
37+
[ -n "$CREDITS" ] || { echo "ERROR: ShieldedCredits deploy failed"; exit 1; }
38+
echo "[proof] shielded_credits=$CREDITS"
39+
40+
COMMITMENT=$(cast keccak "$(cast abi-encode 'f(address,bytes32)' "$USER_ADDR" \
41+
0x1111111111111111111111111111111111111111111111111111111111111111)")
42+
echo "[proof] commitment=$COMMITMENT"
43+
44+
echo "[proof] mint + approve + fundCredits (spendingKey=$USER_ADDR)"
45+
cast send "$TOKEN" "mint(address,uint256)" "$USER_ADDR" "$FUND_AMOUNT" \
46+
--rpc-url "$RPC_URL" --private-key "$DEPLOYER_KEY" >/dev/null || exit 1
47+
cast send "$TOKEN" "approve(address,uint256)" "$CREDITS" "$FUND_AMOUNT" \
48+
--rpc-url "$RPC_URL" --private-key "$USER_KEY" >/dev/null || exit 1
49+
cast send "$CREDITS" "fundCredits(address,uint256,bytes32,address)" \
50+
"$TOKEN" "$FUND_AMOUNT" "$COMMITMENT" "$USER_ADDR" \
51+
--rpc-url "$RPC_URL" --private-key "$USER_KEY" >/dev/null || exit 1
52+
53+
cat > "$ENV_OUT" <<EOF
54+
RPC_URL=$RPC_URL
55+
CHAIN_ID=$CHAIN_ID
56+
SHIELDED_CREDITS=$CREDITS
57+
TOKEN_ADDR=$TOKEN
58+
OPERATOR_ADDR=$OPERATOR_ADDR
59+
DEPLOYER_KEY=$DEPLOYER_KEY
60+
USER_KEY=$USER_KEY
61+
SPENDING_KEY=$USER_ADDR
62+
COMMITMENT=$COMMITMENT
63+
CREDIT_FUND_AMOUNT=$FUND_AMOUNT
64+
EOF
65+
echo "[proof] wrote $ENV_OUT"
66+
67+
echo "[proof] === running SDK on-chain e2e ==="
68+
node "$SDK_DIR/scripts/onchain-e2e.mjs"

0 commit comments

Comments
 (0)