@0xopenseeddev/buyer 1.0.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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@0xopenseeddev/buyer",
3
+ "version": "1.0.0",
4
+ "description": "Buyer proxy — local OpenAI/Anthropic-compatible AI gateway",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "node --watch src/index.js",
9
+ "start": "node src/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@0xopenseeddev/shared": "*",
13
+ "@fastify/cors": "^10.0.1",
14
+ "fastify": "^5.2.1",
15
+ "undici": "^7.3.0",
16
+ "viem": "^2.21.19"
17
+ },
18
+ "keywords": [],
19
+ "license": "ISC"
20
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ChannelState — buyer-side in-memory session tracker.
3
+ *
4
+ * Tracks the open payment channel with each pinned seller so the buyer
5
+ * can attach the correct SpendingAuth signature to every inference request.
6
+ *
7
+ * Lifecycle per seller:
8
+ * NONE → RESERVING → OPEN → CLOSED
9
+ */
10
+
11
+ import { parseUsdc, formatUsdc } from '@0xopenseeddev/shared';
12
+
13
+ export class ChannelState {
14
+ constructor() {
15
+ /** @type {Map<string, ChannelEntry>} sellerAddress → ChannelEntry */
16
+ this._channels = new Map();
17
+
18
+ /** @type {Map<string, number>} sellerAddress → nonce (monotonic) */
19
+ this._nonces = new Map();
20
+ }
21
+
22
+ // ─── Nonce management ─────────────────────────────────────────────────────
23
+
24
+ nextNonce(sellerAddress) {
25
+ const n = (this._nonces.get(sellerAddress) ?? 0) + 1;
26
+ this._nonces.set(sellerAddress, n);
27
+ return n;
28
+ }
29
+
30
+ // ─── Channel entry management ─────────────────────────────────────────────
31
+
32
+ /**
33
+ * Open a new channel entry after the seller acknowledges a ReserveAuth.
34
+ */
35
+ openChannel(sellerAddress, { channelId, maxAmount, buyerAddress }) {
36
+ this._channels.set(sellerAddress, {
37
+ channelId,
38
+ buyerAddress,
39
+ sellerAddress,
40
+ maxAmount,
41
+ cumulativeSpend: 0n,
42
+ requestCount: 0,
43
+ openedAt: Date.now(),
44
+ status: 'open'
45
+ });
46
+ console.log(`[channel-state] Opened channel ${channelId.slice(0, 18)}… with ${sellerAddress.slice(0, 10)}…`);
47
+ }
48
+
49
+ /**
50
+ * Record the cost of a completed inference request and return the
51
+ * new cumulative spend (used to build SpendingAuth).
52
+ *
53
+ * @param {string} sellerAddress
54
+ * @param {bigint} requestCost - USDC cost for this specific request
55
+ * @returns {{ cumulativeSpend: bigint, requestCount: number } | null}
56
+ */
57
+ recordSpend(sellerAddress, requestCost) {
58
+ const ch = this._channels.get(sellerAddress);
59
+ if (!ch || ch.status !== 'open') return null;
60
+
61
+ ch.cumulativeSpend += requestCost;
62
+ ch.requestCount += 1;
63
+
64
+ if (ch.cumulativeSpend > ch.maxAmount) {
65
+ console.warn(`[channel-state] ⚠️ Overspend on channel ${ch.channelId.slice(0, 18)}…`);
66
+ }
67
+
68
+ return { cumulativeSpend: ch.cumulativeSpend, requestCount: ch.requestCount };
69
+ }
70
+
71
+ getChannel(sellerAddress) {
72
+ return this._channels.get(sellerAddress) ?? null;
73
+ }
74
+
75
+ closeChannel(sellerAddress) {
76
+ const ch = this._channels.get(sellerAddress);
77
+ if (ch) {
78
+ ch.status = 'closed';
79
+ ch.closedAt = Date.now();
80
+ console.log(`[channel-state] Closed channel ${ch.channelId.slice(0, 18)}…`);
81
+ }
82
+ }
83
+
84
+ status() {
85
+ const channels = [...this._channels.values()].map(ch => ({
86
+ channelId: ch.channelId,
87
+ sellerAddress: ch.sellerAddress,
88
+ status: ch.status,
89
+ maxAmount: formatUsdc(ch.maxAmount) + ' USDC',
90
+ spent: formatUsdc(ch.cumulativeSpend) + ' USDC',
91
+ requestCount: ch.requestCount,
92
+ openedAt: new Date(ch.openedAt).toISOString()
93
+ }));
94
+ return { channels };
95
+ }
96
+ }
package/src/config.js ADDED
@@ -0,0 +1,23 @@
1
+ // ─── Buyer proxy configuration ────────────────────────────────────────────────
2
+
3
+ export function loadConfig() {
4
+ return {
5
+ // Port the local proxy listens on
6
+ port: Number(process.env.BUYER_PORT ?? 8377),
7
+
8
+ // Registry base URL
9
+ registryUrl: process.env.REGISTRY_URL ?? 'http://localhost:9000',
10
+
11
+ // Pricing caps — reject providers that exceed these rates
12
+ maxPricing: {
13
+ inputUsdPerMillion: Number(process.env.MAX_INPUT_USD ?? 50),
14
+ outputUsdPerMillion: Number(process.env.MAX_OUTPUT_USD ?? 100)
15
+ },
16
+
17
+ // Default session budget in USDC (overridden by CONTRACT_ADDRESS / BUYER_PRIVATE_KEY env)
18
+ defaultBudgetUsdc: Number(process.env.DEFAULT_BUDGET_USDC ?? 1),
19
+
20
+ // Minimum peer reputation score (0 = accept all, reserved for Phase 5)
21
+ minPeerReputation: 0
22
+ };
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,311 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import { fetch } from 'undici';
4
+ import { BUYER_PORT, parseUsdc } from '@0xopenseeddev/shared';
5
+ import { loadConfig } from './config.js';
6
+ import { PeerRouter } from './peer-router.js';
7
+ import { BuyerWallet } from './wallet.js';
8
+ import { ChannelState } from './channel-state.js';
9
+ import { RequestTracker } from './request-tracker.js';
10
+ import { createBuyerMetrics } from './metrics.js';
11
+
12
+ const cfg = loadConfig();
13
+ const tracker = new RequestTracker();
14
+ const router = new PeerRouter(cfg.registryUrl, cfg.maxPricing, tracker);
15
+ const channels = new ChannelState();
16
+ const { reg, m } = createBuyerMetrics();
17
+ const _startTime = Date.now();
18
+
19
+ // ─── Payment wallet (optional) ────────────────────────────────────────────────
20
+ let wallet = null;
21
+ if (process.env.BUYER_PRIVATE_KEY && process.env.CONTRACT_ADDRESS) {
22
+ wallet = new BuyerWallet({
23
+ privateKey: process.env.BUYER_PRIVATE_KEY,
24
+ contractAddress: process.env.CONTRACT_ADDRESS,
25
+ chainId: Number(process.env.CHAIN_ID ?? 31337),
26
+ defaultBudgetUsdc: cfg.defaultBudgetUsdc
27
+ });
28
+ console.log(`[buyer] 💳 Payment wallet: ${wallet.address}`);
29
+ } else {
30
+ console.log('[buyer] ⚠️ No BUYER_PRIVATE_KEY / CONTRACT_ADDRESS — running in no-payment mode.');
31
+ }
32
+
33
+ // ─── Fastify instance ─────────────────────────────────────────────────────────
34
+ const app = Fastify({ logger: true });
35
+ await app.register(cors, { origin: '*' });
36
+
37
+ // ─── Generic inference forwarder ──────────────────────────────────────────────
38
+ async function forwardInference(req, reply, upstreamPath) {
39
+ const requestedModel = req.body?.model;
40
+ if (!requestedModel) {
41
+ return reply.status(400).send({
42
+ error: { code: 'missing_model', message: 'The "model" field is required.' }
43
+ });
44
+ }
45
+
46
+ const perRequestPeerId = req.headers['x-anteseed-pin-peer'] ?? null;
47
+
48
+ let endpoint, resolvedServiceId, peer;
49
+ try {
50
+ const resolved = await router.resolve(requestedModel, perRequestPeerId);
51
+ endpoint = resolved.endpoint;
52
+ resolvedServiceId = resolved.resolvedServiceId;
53
+ peer = resolved.peer;
54
+ } catch (err) {
55
+ return reply.status(503).send({
56
+ error: { code: err.code ?? 'routing_error', message: err.message, hint: err.hint }
57
+ });
58
+ }
59
+
60
+ const forwardBody = { ...req.body, model: resolvedServiceId };
61
+ const forwardHeaders = { 'content-type': 'application/json' };
62
+
63
+ if (req.headers['anthropic-version']) {
64
+ forwardHeaders['anthropic-version'] = req.headers['anthropic-version'];
65
+ }
66
+ if (req.headers['x-402-payment-auth']) {
67
+ forwardHeaders['x-402-payment-auth'] = req.headers['x-402-payment-auth'];
68
+ }
69
+ if (req.headers['x-402-channel-id']) {
70
+ forwardHeaders['x-402-channel-id'] = req.headers['x-402-channel-id'];
71
+ }
72
+ if (req.headers['x-openseed-buyer-address']) {
73
+ forwardHeaders['x-openseed-buyer-address'] = req.headers['x-openseed-buyer-address'];
74
+ } else if (wallet) {
75
+ forwardHeaders['x-openseed-buyer-address'] = wallet.address;
76
+ }
77
+
78
+ const targetUrl = `${endpoint}${upstreamPath}`;
79
+ const t0 = Date.now();
80
+
81
+ const makeRequest = async (headers) => {
82
+ return fetch(targetUrl, {
83
+ method: 'POST',
84
+ headers,
85
+ body: JSON.stringify(forwardBody)
86
+ });
87
+ };
88
+
89
+ try {
90
+ let peerRes = await makeRequest(forwardHeaders);
91
+
92
+ // ── Handle x402 Payment Retry ─────────────────────────────────────────────
93
+ if (peerRes.status === 402 && wallet) {
94
+ const paymentRequired = peerRes.headers.get('x-402-payment-required');
95
+ if (paymentRequired) {
96
+ const match = paymentRequired.match(/price=(\d+)/);
97
+ const price = match ? BigInt(match[1]) : parseUsdc(0.01);
98
+ const sellerAddress = peer?.merchantAddress ?? peer?.peerId ?? endpoint;
99
+ let ch = channels.getChannel(sellerAddress);
100
+
101
+ if (!ch || ch.status !== 'open') {
102
+ const nonce = channels.nextNonce(sellerAddress);
103
+ const reserveAuth = await wallet.buildReserveAuth(
104
+ sellerAddress,
105
+ nonce,
106
+ parseUsdc(Math.min(cfg.defaultBudgetUsdc, 1))
107
+ );
108
+ try {
109
+ const sessionRes = await fetch(`${endpoint}/session/start`, {
110
+ method: 'POST',
111
+ headers: { 'content-type': 'application/json' },
112
+ body: JSON.stringify({
113
+ channelId: reserveAuth.channelId,
114
+ buyerAddress: wallet.address,
115
+ sellerAddress: sellerAddress,
116
+ maxAmount: reserveAuth.maxAmount.toString(),
117
+ deadline: reserveAuth.deadline.toString(),
118
+ signature: reserveAuth.signature
119
+ })
120
+ });
121
+ const sessionData = await sessionRes.json().catch(() => null);
122
+ if (!sessionRes.ok) {
123
+ throw new Error(sessionData?.error?.message || 'Failed to start x402 session');
124
+ }
125
+ channels.openChannel(sellerAddress, {
126
+ channelId: reserveAuth.channelId,
127
+ maxAmount: reserveAuth.maxAmount,
128
+ buyerAddress: wallet.address
129
+ });
130
+ ch = channels.getChannel(sellerAddress);
131
+ m.activeChannels.set({}, channels.status().channels.filter(c => c.status === 'open').length);
132
+ } catch (err) {
133
+ app.log.warn('[buyer] Could not start payment session:', err.message);
134
+ }
135
+ }
136
+
137
+ if (ch && ch.status === 'open') {
138
+ const spend = channels.recordSpend(sellerAddress, price);
139
+ if (spend) {
140
+ const meta = { inputTokens: 0, outputTokens: 0, requestId: `req-${Date.now()}` };
141
+ const spendingAuth = await wallet.buildSpendingAuth(ch.channelId, spend.cumulativeSpend, meta);
142
+ const encoded = Buffer.from(JSON.stringify({
143
+ cumulativeAmount: spendingAuth.cumulativeAmount.toString(),
144
+ metadataHash: spendingAuth.metadataHash,
145
+ signature: spendingAuth.signature
146
+ })).toString('base64');
147
+ forwardHeaders['x-402-payment-auth'] = encoded;
148
+ forwardHeaders['x-402-channel-id'] = ch.channelId;
149
+
150
+ peerRes = await makeRequest(forwardHeaders);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ const latencyMs = Date.now() - t0;
157
+ const success = peerRes.status >= 200 && peerRes.status < 300;
158
+ const peerId = peer?.peerId ?? endpoint;
159
+
160
+ // ── Track request + update Prometheus metrics ──────────────────────────
161
+ tracker.record(peerId, { latencyMs, success, statusCode: peerRes.status, model: resolvedServiceId });
162
+
163
+ m.requestsTotal.inc({ peer_id: peerId.slice(0, 12), model: resolvedServiceId, status: String(peerRes.status) });
164
+ m.requestDurationMs.observe({ peer_id: peerId.slice(0, 12), model: resolvedServiceId }, latencyMs);
165
+
166
+ // Refresh peer reputation gauges
167
+ const stats = tracker.getStats(peerId);
168
+ if (stats) {
169
+ m.peerReputationScore.set({ peer_id: peerId.slice(0, 12) }, stats.reputationScore);
170
+ m.peerSuccessRate.set({ peer_id: peerId.slice(0, 12) }, stats.successRate);
171
+ m.peerLatencyP95Ms.set({ peer_id: peerId.slice(0, 12) }, stats.latency.p95);
172
+ }
173
+
174
+ reply.status(peerRes.status);
175
+ const ct = peerRes.headers.get('content-type') ?? 'application/json';
176
+ reply.header('content-type', ct);
177
+ if (peerRes.body) return reply.send(peerRes.body);
178
+ return reply.send(await peerRes.text());
179
+ } catch (err) {
180
+ const latencyMs = Date.now() - t0;
181
+ tracker.record(peer?.peerId ?? endpoint, { latencyMs, success: false, statusCode: 502, model: resolvedServiceId });
182
+ m.requestsTotal.inc({ peer_id: (peer?.peerId ?? endpoint).slice(0, 12), model: resolvedServiceId, status: '502' });
183
+ return reply.status(502).send({ error: { code: 'peer_unreachable', message: err.message } });
184
+ }
185
+ }
186
+
187
+ // ── Inference routes ──────────────────────────────────────────────────────────
188
+ app.post('/v1/chat/completions', (req, reply) => forwardInference(req, reply, '/v1/chat/completions'));
189
+ app.post('/v1/messages', (req, reply) => forwardInference(req, reply, '/v1/messages'));
190
+ app.post('/v1/responses', (req, reply) => forwardInference(req, reply, '/v1/responses'));
191
+
192
+ // ── Prometheus metrics ────────────────────────────────────────────────────────
193
+ app.get('/metrics', async (req, reply) => {
194
+ // Refresh gauges that are computed on-demand
195
+ m.proxyUptimeSeconds.set({}, Math.floor((Date.now() - _startTime) / 1000));
196
+ m.registeredPeers.set({}, router.cachedPeerCount);
197
+ m.activeChannels.set({}, channels.status().channels.filter(c => c.status === 'open').length);
198
+
199
+ // Refresh all peer reputation gauges
200
+ for (const stats of tracker.getAllStats()) {
201
+ const shortId = stats.peerId.slice(0, 12);
202
+ m.peerReputationScore.set({ peer_id: shortId }, stats.reputationScore);
203
+ m.peerSuccessRate.set({ peer_id: shortId }, stats.successRate);
204
+ m.peerLatencyP95Ms.set({ peer_id: shortId }, stats.latency.p95);
205
+ m.spendUsdcTotal.set({ peer_id: shortId }, Number(stats.totalSpendUsdc));
206
+ }
207
+
208
+ reply.header('content-type', 'text/plain; version=0.0.4; charset=utf-8');
209
+ return reply.send(reg.format());
210
+ });
211
+
212
+ // ── Reputation / stats API ────────────────────────────────────────────────────
213
+ app.get('/reputation/peers', async () => ({
214
+ global: tracker.globalStats(),
215
+ peers: tracker.getAllStats()
216
+ }));
217
+
218
+ app.get('/reputation/peers/:peerId', async (req, reply) => {
219
+ const stats = tracker.getStats(req.params.peerId);
220
+ if (!stats) return reply.status(404).send({ error: { code: 'not_found', message: 'No data for this peer yet.' } });
221
+ return stats;
222
+ });
223
+
224
+ // ── Buyer management API ──────────────────────────────────────────────────────
225
+ app.get('/buyer/status', async () => ({
226
+ wallet: wallet ? { address: wallet.address, paymentsEnabled: true } : { paymentsEnabled: false },
227
+ pinnedPeerId: router.pinnedPeerId,
228
+ pinnedServiceId: router.pinnedServiceId,
229
+ registryUrl: cfg.registryUrl,
230
+ maxPricing: cfg.maxPricing,
231
+ global: tracker.globalStats(),
232
+ ...channels.status()
233
+ }));
234
+
235
+ app.post('/buyer/connection', {
236
+ schema: {
237
+ body: {
238
+ type: 'object',
239
+ properties: { peer: { type: 'string' }, service: { type: 'string' } }
240
+ }
241
+ }
242
+ }, async (req) => {
243
+ const { peer, service } = req.body ?? {};
244
+ if (peer) router.setPeer(peer);
245
+ if (service) router.setService(service);
246
+ return { ok: true, pinnedPeerId: router.pinnedPeerId, pinnedServiceId: router.pinnedServiceId };
247
+ });
248
+
249
+ app.delete('/buyer/connection', async () => {
250
+ router.clearPins();
251
+ return { ok: true };
252
+ });
253
+
254
+ app.get('/payments/status', async () => ({
255
+ paymentsEnabled: !!wallet,
256
+ wallet: wallet ? { address: wallet.address } : null,
257
+ ...channels.status()
258
+ }));
259
+
260
+ app.get('/network/peers', async (req) => {
261
+ const { model } = req.query;
262
+ const url = model
263
+ ? `${cfg.registryUrl}/peers?model=${encodeURIComponent(model)}`
264
+ : `${cfg.registryUrl}/peers`;
265
+ const res = await fetch(url);
266
+ return res.json();
267
+ });
268
+
269
+ app.get('/network/peers/:peerId', async (req, reply) => {
270
+ const res = await fetch(`${cfg.registryUrl}/peers/${req.params.peerId}`);
271
+ reply.status(res.status);
272
+ return res.json();
273
+ });
274
+
275
+ app.get('/health', async () => ({
276
+ status: 'ok',
277
+ proxy: 'buyer',
278
+ port: cfg.port,
279
+ payments: !!wallet,
280
+ ...tracker.globalStats()
281
+ }));
282
+
283
+ // ─── Start ────────────────────────────────────────────────────────────────────
284
+ try {
285
+ await app.listen({ port: cfg.port, host: '127.0.0.1' });
286
+ console.log(`
287
+ 🐜 OpenSeed Buyer Proxy http://localhost:${cfg.port}
288
+
289
+ Inference endpoints:
290
+ POST /v1/chat/completions (OpenAI format)
291
+ POST /v1/messages (Anthropic format)
292
+
293
+ Observability:
294
+ GET /metrics (Prometheus text format)
295
+ GET /reputation/peers (per-peer runtime stats + scores)
296
+ GET /buyer/status (full status)
297
+
298
+ Payments: ${wallet ? '💳 enabled — wallet ' + wallet.address : '⚠️ disabled (no-payment mode)'}
299
+ `);
300
+ } catch (err) {
301
+ app.log.error(err);
302
+ process.exit(1);
303
+ }
304
+
305
+ async function shutdown() {
306
+ console.log('\n[buyer] Shutting down…');
307
+ await app.close();
308
+ process.exit(0);
309
+ }
310
+ process.on('SIGINT', shutdown);
311
+ process.on('SIGTERM', shutdown);
package/src/metrics.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Buyer Prometheus metrics registry.
3
+ * Exposes all buyer-proxy metrics at GET /metrics.
4
+ */
5
+
6
+ import { PrometheusRegistry } from '@0xopenseeddev/shared';
7
+
8
+ export function createBuyerMetrics() {
9
+ const reg = new PrometheusRegistry();
10
+
11
+ const m = {
12
+ // ── Inference requests ───────────────────────────────────────────────────
13
+ requestsTotal: reg.counter(
14
+ 'openseed_buyer_requests_total',
15
+ 'Total inference requests forwarded by the buyer proxy',
16
+ ['peer_id', 'model', 'status']
17
+ ),
18
+
19
+ requestDurationMs: reg.histogram(
20
+ 'openseed_buyer_request_duration_ms',
21
+ 'End-to-end latency of forwarded requests in milliseconds',
22
+ [10, 25, 50, 100, 250, 500, 1000, 2500, 5000],
23
+ ['peer_id', 'model']
24
+ ),
25
+
26
+ // ── Token accounting ─────────────────────────────────────────────────────
27
+ inputTokensTotal: reg.counter(
28
+ 'openseed_buyer_input_tokens_total',
29
+ 'Total input tokens sent across all requests',
30
+ ['peer_id', 'model']
31
+ ),
32
+
33
+ outputTokensTotal: reg.counter(
34
+ 'openseed_buyer_output_tokens_total',
35
+ 'Total output tokens received across all requests',
36
+ ['peer_id', 'model']
37
+ ),
38
+
39
+ // ── Payments ─────────────────────────────────────────────────────────────
40
+ spendUsdcTotal: reg.gauge(
41
+ 'openseed_buyer_spend_usdc_total',
42
+ 'Cumulative USDC spent (in base units, 6 decimal)',
43
+ ['peer_id']
44
+ ),
45
+
46
+ activeChannels: reg.gauge(
47
+ 'openseed_buyer_active_channels',
48
+ 'Number of currently open payment channels'
49
+ ),
50
+
51
+ // ── Peer reputation ───────────────────────────────────────────────────────
52
+ peerReputationScore: reg.gauge(
53
+ 'openseed_buyer_peer_reputation_score',
54
+ 'Composite reputation score for each peer (0-100)',
55
+ ['peer_id']
56
+ ),
57
+
58
+ peerSuccessRate: reg.gauge(
59
+ 'openseed_buyer_peer_success_rate',
60
+ 'Success rate for each peer (0-1)',
61
+ ['peer_id']
62
+ ),
63
+
64
+ peerLatencyP95Ms: reg.gauge(
65
+ 'openseed_buyer_peer_latency_p95_ms',
66
+ 'P95 request latency per peer in milliseconds',
67
+ ['peer_id']
68
+ ),
69
+
70
+ // ── Proxy internals ───────────────────────────────────────────────────────
71
+ proxyUptimeSeconds: reg.gauge(
72
+ 'openseed_buyer_uptime_seconds',
73
+ 'Seconds since the buyer proxy started'
74
+ ),
75
+
76
+ registeredPeers: reg.gauge(
77
+ 'openseed_buyer_known_peers_total',
78
+ 'Number of peers known from the last registry fetch'
79
+ )
80
+ };
81
+
82
+ return { reg, m };
83
+ }
@@ -0,0 +1,172 @@
1
+ import { fetch } from 'undici';
2
+
3
+ /**
4
+ * PeerRouter is responsible for:
5
+ * 1. Fetching live peers from the registry
6
+ * 2. Filtering by model/service and max pricing
7
+ * 3. Maintaining a "pinned" peer per session
8
+ * 4. Selecting the BEST peer by reputation score when none is pinned
9
+ *
10
+ * Phase 5 addition: accepts an optional RequestTracker to score peers by
11
+ * runtime latency, reliability, and activity when auto-selecting.
12
+ */
13
+ export class PeerRouter {
14
+ /**
15
+ * @param {string} registryUrl
16
+ * @param {{ inputUsdPerMillion: number, outputUsdPerMillion: number }} maxPricing
17
+ * @param {import('./request-tracker.js').RequestTracker} [tracker]
18
+ */
19
+ constructor(registryUrl, maxPricing, tracker = null) {
20
+ this.registryUrl = registryUrl;
21
+ this.maxPricing = maxPricing;
22
+ this.tracker = tracker;
23
+
24
+ /** @type {string|null} peerId of the currently pinned peer */
25
+ this.pinnedPeerId = null;
26
+
27
+ /** @type {string|null} override all model fields to this service id */
28
+ this.pinnedServiceId = null;
29
+
30
+ /** @type {Map<string, any>} in-memory cache of peers fetched from registry */
31
+ this._peerCache = new Map();
32
+ this._cacheTs = 0;
33
+ this._cacheTtl = 15_000; // 15 s
34
+ }
35
+
36
+ // ── Pin controls ──────────────────────────────────────────────────────────
37
+
38
+ setPeer(peerId) {
39
+ this.pinnedPeerId = peerId;
40
+ console.log(`[buyer-router] Pinned peer: ${peerId.slice(0, 16)}…`);
41
+ }
42
+
43
+ setService(serviceId) {
44
+ this.pinnedServiceId = serviceId;
45
+ console.log(`[buyer-router] Pinned service: ${serviceId}`);
46
+ }
47
+
48
+ clearPins() {
49
+ this.pinnedPeerId = null;
50
+ this.pinnedServiceId = null;
51
+ console.log('[buyer-router] Pins cleared.');
52
+ }
53
+
54
+ // ── Peer resolution ───────────────────────────────────────────────────────
55
+
56
+ async _fetchPeers(model) {
57
+ const now = Date.now();
58
+ if (now - this._cacheTs < this._cacheTtl && this._peerCache.size > 0) {
59
+ return [...this._peerCache.values()];
60
+ }
61
+ const url = model
62
+ ? `${this.registryUrl}/peers?model=${encodeURIComponent(model)}`
63
+ : `${this.registryUrl}/peers`;
64
+ const res = await fetch(url);
65
+ const data = await res.json();
66
+ this._peerCache.clear();
67
+ for (const p of (data.peers ?? [])) {
68
+ this._peerCache.set(p.peerId, p);
69
+ }
70
+ this._cacheTs = now;
71
+ return [...this._peerCache.values()];
72
+ }
73
+
74
+ /**
75
+ * Select the best eligible peer for a model using the reputation scorer.
76
+ *
77
+ * Scoring priority:
78
+ * 1. Reputation score (reliability × latency × activity) — 60%
79
+ * 2. Price (cheapest output/M tokens) — 40%
80
+ *
81
+ * Peers with no history get a neutral score of 50.
82
+ *
83
+ * @param {any[]} peers
84
+ * @param {string} serviceId
85
+ * @returns {any|null}
86
+ */
87
+ _selectBestPeer(peers, serviceId) {
88
+ if (peers.length === 0) return null;
89
+ if (peers.length === 1) return peers[0];
90
+
91
+ const MAX_OUTPUT_PRICE = this.maxPricing.outputUsdPerMillion;
92
+
93
+ const scored = peers.map(p => {
94
+ const offering = p.offerings?.find(o => o.services?.includes(serviceId));
95
+ const price = offering?.pricing?.outputUsdPerMillion ?? MAX_OUTPUT_PRICE;
96
+ const repScore = this.tracker ? this.tracker.score(p.peerId) : 50;
97
+ const priceScore = Math.max(0, 1 - price / MAX_OUTPUT_PRICE) * 100;
98
+ const composite = (repScore * 0.60) + (priceScore * 0.40);
99
+ return { peer: p, score: composite, repScore, priceScore, price };
100
+ });
101
+
102
+ scored.sort((a, b) => b.score - a.score);
103
+
104
+ // Log the selection decision (debug)
105
+ const top = scored[0];
106
+ console.log(
107
+ `[buyer-router] Auto-selected: ${top.peer.peerId.slice(0, 16)}… ` +
108
+ `(rep=${top.repScore}, price=${top.priceScore.toFixed(1)}, composite=${top.score.toFixed(1)})`
109
+ );
110
+
111
+ return top.peer;
112
+ }
113
+
114
+ /**
115
+ * Resolve which peer endpoint to use for a given request.
116
+ * Returns { endpoint, resolvedServiceId, peer } or throws an error.
117
+ *
118
+ * @param {string} requestedModel
119
+ * @param {string|null} perRequestPeerId - from x-anteseed-pin-peer header
120
+ */
121
+ async resolve(requestedModel, perRequestPeerId = null) {
122
+ const effectivePeerId = perRequestPeerId ?? this.pinnedPeerId;
123
+ const effectiveServiceId = this.pinnedServiceId ?? requestedModel;
124
+
125
+ if (!effectivePeerId) {
126
+ // Auto-select: fetch eligible peers and score them
127
+ const allPeers = await this._fetchPeers(effectiveServiceId);
128
+ const eligible = allPeers.filter(p =>
129
+ p.offerings?.some(o =>
130
+ o.services?.includes(effectiveServiceId) &&
131
+ o.pricing.inputUsdPerMillion <= this.maxPricing.inputUsdPerMillion &&
132
+ o.pricing.outputUsdPerMillion <= this.maxPricing.outputUsdPerMillion
133
+ )
134
+ );
135
+
136
+ if (eligible.length === 0) {
137
+ const err = new Error('no_peer_available');
138
+ err.code = 'no_peer_available';
139
+ err.hint = `No peers offer "${effectiveServiceId}" within your pricing caps. ` +
140
+ `Browse peers at GET /network/peers and pin one via POST /buyer/connection.`;
141
+ throw err;
142
+ }
143
+
144
+ const best = this._selectBestPeer(eligible, effectiveServiceId);
145
+ return { endpoint: best.endpoint, resolvedServiceId: effectiveServiceId, peer: best };
146
+ }
147
+
148
+ // Pinned peer — look up from cache or registry
149
+ const cached = this._peerCache.get(effectivePeerId);
150
+ if (cached) {
151
+ return { endpoint: cached.endpoint, resolvedServiceId: effectiveServiceId, peer: cached };
152
+ }
153
+
154
+ const res = await fetch(`${this.registryUrl}/peers/${effectivePeerId}`);
155
+ if (!res.ok) {
156
+ const err = new Error('pinned_peer_offline');
157
+ err.code = 'pinned_peer_offline';
158
+ err.hint = `The pinned peer ${effectivePeerId.slice(0, 16)}… is not reachable.`;
159
+ throw err;
160
+ }
161
+ const peer = await res.json();
162
+ this._peerCache.set(peer.peerId, peer);
163
+ return { endpoint: peer.endpoint, resolvedServiceId: effectiveServiceId, peer };
164
+ }
165
+
166
+ /**
167
+ * Return the number of cached peers (for metrics).
168
+ */
169
+ get cachedPeerCount() {
170
+ return this._peerCache.size;
171
+ }
172
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * RequestTracker — buyer proxy
3
+ *
4
+ * Tracks per-peer runtime statistics:
5
+ * - Request counts (success / failure)
6
+ * - Latency samples (rolling window of last 100)
7
+ * - Token counts (input / output)
8
+ * - USDC spend
9
+ *
10
+ * These stats feed into the reputation scoring engine so the peer-router
11
+ * can auto-select the best available peer for each request.
12
+ */
13
+
14
+ export class RequestTracker {
15
+ constructor() {
16
+ /** @type {Map<string, PeerStats>} peerId → stats */
17
+ this._peers = new Map();
18
+ this._globalStart = Date.now();
19
+ this._totalRequests = 0;
20
+ this._totalFailed = 0;
21
+ }
22
+
23
+ // ─── Recording ────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Record the outcome of a forwarded inference request.
27
+ *
28
+ * @param {string} peerId
29
+ * @param {object} opts
30
+ * @param {number} opts.latencyMs - wall-clock time from send → first byte
31
+ * @param {boolean} opts.success - whether the upstream returned 2xx
32
+ * @param {number} [opts.statusCode] - HTTP status code
33
+ * @param {number} [opts.inputTokens] - parsed from usage (if available)
34
+ * @param {number} [opts.outputTokens]
35
+ * @param {bigint} [opts.costUsdc] - USDC spent in base units
36
+ * @param {string} [opts.model] - resolved service/model name
37
+ */
38
+ record(peerId, {
39
+ latencyMs,
40
+ success,
41
+ statusCode = 200,
42
+ inputTokens = 0,
43
+ outputTokens = 0,
44
+ costUsdc = 0n,
45
+ model = 'unknown'
46
+ }) {
47
+ let s = this._peers.get(peerId);
48
+ if (!s) {
49
+ s = this._newStats(peerId);
50
+ this._peers.set(peerId, s);
51
+ }
52
+
53
+ s.totalRequests++;
54
+ this._totalRequests++;
55
+
56
+ if (!success) {
57
+ s.failedRequests++;
58
+ this._totalFailed++;
59
+ s.lastStatusCode = statusCode;
60
+ } else {
61
+ s.successfulRequests++;
62
+ }
63
+
64
+ // Rolling latency window (last 100 samples)
65
+ s.latencySamples.push(latencyMs);
66
+ if (s.latencySamples.length > 100) s.latencySamples.shift();
67
+
68
+ s.totalInputTokens += inputTokens;
69
+ s.totalOutputTokens += outputTokens;
70
+ s.totalSpendUsdc += costUsdc;
71
+ s.lastSeenAt = Date.now();
72
+ s.lastModel = model;
73
+ }
74
+
75
+ // ─── Derived stats ────────────────────────────────────────────────────────
76
+
77
+ getStats(peerId) {
78
+ const s = this._peers.get(peerId);
79
+ if (!s) return null;
80
+ return this._enrichStats(s);
81
+ }
82
+
83
+ getAllStats() {
84
+ return [...this._peers.values()].map(s => this._enrichStats(s));
85
+ }
86
+
87
+ globalStats() {
88
+ return {
89
+ totalRequests: this._totalRequests,
90
+ totalFailed: this._totalFailed,
91
+ successRate: this._totalRequests > 0
92
+ ? ((this._totalRequests - this._totalFailed) / this._totalRequests)
93
+ : 1,
94
+ uptimeSeconds: Math.floor((Date.now() - this._globalStart) / 1000),
95
+ peerCount: this._peers.size
96
+ };
97
+ }
98
+
99
+ _enrichStats(s) {
100
+ const sorted = [...s.latencySamples].sort((a, b) => a - b);
101
+ const p50 = percentile(sorted, 50);
102
+ const p95 = percentile(sorted, 95);
103
+ const p99 = percentile(sorted, 99);
104
+ const avg = sorted.length > 0
105
+ ? sorted.reduce((a, b) => a + b, 0) / sorted.length
106
+ : 0;
107
+ const successRate = s.totalRequests > 0
108
+ ? s.successfulRequests / s.totalRequests
109
+ : 1;
110
+ return {
111
+ peerId: s.peerId,
112
+ totalRequests: s.totalRequests,
113
+ successfulRequests: s.successfulRequests,
114
+ failedRequests: s.failedRequests,
115
+ successRate: +successRate.toFixed(4),
116
+ latency: { avg: +avg.toFixed(1), p50, p95, p99, sampleCount: sorted.length },
117
+ totalInputTokens: s.totalInputTokens,
118
+ totalOutputTokens: s.totalOutputTokens,
119
+ totalSpendUsdc: s.totalSpendUsdc.toString(),
120
+ lastSeenAt: s.lastSeenAt ? new Date(s.lastSeenAt).toISOString() : null,
121
+ lastModel: s.lastModel,
122
+ lastStatusCode: s.lastStatusCode,
123
+ reputationScore: this.score(s.peerId)
124
+ };
125
+ }
126
+
127
+ _newStats(peerId) {
128
+ return {
129
+ peerId,
130
+ totalRequests: 0,
131
+ successfulRequests: 0,
132
+ failedRequests: 0,
133
+ latencySamples: [],
134
+ totalInputTokens: 0,
135
+ totalOutputTokens: 0,
136
+ totalSpendUsdc: 0n,
137
+ lastSeenAt: null,
138
+ lastModel: null,
139
+ lastStatusCode: null
140
+ };
141
+ }
142
+
143
+ // ─── Reputation scoring ───────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Composite reputation score for a peer: 0 (worst) → 100 (best).
147
+ *
148
+ * Weights:
149
+ * 40% — reliability (success rate)
150
+ * 35% — latency (lower p95 is better; baseline 2000ms)
151
+ * 25% — activity (logarithmic request volume, normalized to 200 req)
152
+ *
153
+ * On-chain volume score is reserved for Phase 5+ once we have a public
154
+ * chain indexer set up; for now activity is proxy-measured locally.
155
+ *
156
+ * @param {string} peerId
157
+ * @returns {number} 0-100
158
+ */
159
+ score(peerId) {
160
+ const s = this._peers.get(peerId);
161
+ if (!s || s.totalRequests === 0) return 50; // neutral for unseen peers
162
+
163
+ const successRate = s.successfulRequests / s.totalRequests;
164
+
165
+ const sorted = [...s.latencySamples].sort((a, b) => a - b);
166
+ const p95 = percentile(sorted, 95);
167
+ const BASELINE_LATENCY_MS = 2000;
168
+ const latencyScore = Math.max(0, 1 - p95 / BASELINE_LATENCY_MS);
169
+
170
+ const MAX_REQUESTS = 200;
171
+ const activityScore = Math.min(1, Math.log1p(s.totalRequests) / Math.log1p(MAX_REQUESTS));
172
+
173
+ const composite = (0.40 * successRate) + (0.35 * latencyScore) + (0.25 * activityScore);
174
+ return Math.round(composite * 100);
175
+ }
176
+
177
+ /**
178
+ * Sort a list of peer IDs by descending reputation score.
179
+ * Peers with no history score 50 (neutral).
180
+ */
181
+ rankPeers(peerIds) {
182
+ return [...peerIds].sort((a, b) => this.score(b) - this.score(a));
183
+ }
184
+ }
185
+
186
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
187
+
188
+ function percentile(sortedArr, p) {
189
+ if (sortedArr.length === 0) return 0;
190
+ const idx = Math.ceil((p / 100) * sortedArr.length) - 1;
191
+ return +(sortedArr[Math.max(0, idx)] ?? 0).toFixed(1);
192
+ }
package/src/wallet.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Buyer wallet — wraps the buyer's private key and manages EIP-712 signing
3
+ * for ReserveAuth and SpendingAuth messages.
4
+ *
5
+ * In production, the private key never leaves this process.
6
+ * On-chain actions (reserve, settle, close) are initiated by the seller node.
7
+ * The buyer only signs typed-data messages — it never needs gas.
8
+ */
9
+
10
+ import { privateKeyToAccount } from 'viem/accounts';
11
+ import {
12
+ signReserveAuth,
13
+ signSpendingAuth,
14
+ buildChannelId,
15
+ buildMetadataHash,
16
+ parseUsdc
17
+ } from '@0xopenseeddev/shared';
18
+
19
+ export class BuyerWallet {
20
+ /**
21
+ * @param {object} opts
22
+ * @param {string} opts.privateKey - 0x-prefixed hex private key
23
+ * @param {string} opts.contractAddress - AntseedDeposits contract address
24
+ * @param {number} opts.chainId
25
+ * @param {number} [opts.defaultBudgetUsdc=1] - Default session budget in USDC
26
+ */
27
+ constructor({ privateKey, contractAddress, chainId, defaultBudgetUsdc = 1 }) {
28
+ if (!privateKey) throw new Error('BUYER_PRIVATE_KEY env var is required for payment mode');
29
+ this.account = privateKeyToAccount(privateKey);
30
+ this.contractAddress = contractAddress;
31
+ this.chainId = chainId;
32
+ this.defaultBudget = parseUsdc(defaultBudgetUsdc);
33
+ this._signerOpts = { privateKey, contractAddress, chainId };
34
+ }
35
+
36
+ get address() {
37
+ return this.account.address;
38
+ }
39
+
40
+ // ── ReserveAuth ─────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Sign a ReserveAuth for a new session with a seller.
44
+ *
45
+ * @param {string} sellerAddress
46
+ * @param {number} nonce - increments per buyer-seller pair
47
+ * @param {bigint} [maxAmount] - defaults to defaultBudget
48
+ * @param {number} [ttlSeconds] - defaults to 24h
49
+ * @returns {Promise<{ channelId: string, signature: string, maxAmount: bigint, deadline: bigint }>}
50
+ */
51
+ async buildReserveAuth(sellerAddress, nonce, maxAmount, ttlSeconds = 86_400) {
52
+ const channelId = buildChannelId(this.address, sellerAddress, nonce);
53
+ const amount = maxAmount ?? this.defaultBudget;
54
+ const deadline = BigInt(Math.floor(Date.now() / 1000) + ttlSeconds);
55
+
56
+ const signature = await signReserveAuth(this._signerOpts, {
57
+ channelId,
58
+ seller: sellerAddress,
59
+ maxAmount: amount,
60
+ deadline
61
+ });
62
+
63
+ return { channelId, signature, maxAmount: amount, deadline };
64
+ }
65
+
66
+ // ── SpendingAuth ─────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Sign a SpendingAuth after a successful inference request.
70
+ *
71
+ * @param {string} channelId
72
+ * @param {bigint} cumulativeAmount - total USDC spent in this session so far
73
+ * @param {{ inputTokens: number, outputTokens: number, requestId: string }} meta
74
+ * @returns {Promise<{ cumulativeAmount: bigint, metadataHash: string, signature: string }>}
75
+ */
76
+ async buildSpendingAuth(channelId, cumulativeAmount, meta) {
77
+ const metadataHash = buildMetadataHash(meta);
78
+ const signature = await signSpendingAuth(this._signerOpts, {
79
+ channelId,
80
+ cumulativeAmount,
81
+ metadataHash
82
+ });
83
+ return { cumulativeAmount, metadataHash, signature };
84
+ }
85
+ }