@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 +20 -0
- package/src/channel-state.js +96 -0
- package/src/config.js +23 -0
- package/src/index.js +311 -0
- package/src/metrics.js +83 -0
- package/src/peer-router.js +172 -0
- package/src/request-tracker.js +192 -0
- package/src/wallet.js +85 -0
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
|
+
}
|