@genkaku/protocol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bridge.d.ts +46 -0
  3. package/dist/bridge.d.ts.map +1 -0
  4. package/dist/bridge.js +542 -0
  5. package/dist/chain.d.ts +496 -0
  6. package/dist/chain.d.ts.map +1 -0
  7. package/dist/chain.js +763 -0
  8. package/dist/credit.d.ts +35 -0
  9. package/dist/credit.d.ts.map +1 -0
  10. package/dist/credit.js +112 -0
  11. package/dist/dial.d.ts +43 -0
  12. package/dist/dial.d.ts.map +1 -0
  13. package/dist/dial.js +266 -0
  14. package/dist/directory.d.ts +39 -0
  15. package/dist/directory.d.ts.map +1 -0
  16. package/dist/directory.js +149 -0
  17. package/dist/discovery.d.ts +20 -0
  18. package/dist/discovery.d.ts.map +1 -0
  19. package/dist/discovery.js +58 -0
  20. package/dist/hello.d.ts +14 -0
  21. package/dist/hello.d.ts.map +1 -0
  22. package/dist/hello.js +42 -0
  23. package/dist/index.d.ts +32 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +32 -0
  26. package/dist/keys.d.ts +30 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +126 -0
  29. package/dist/llmBridge.d.ts +34 -0
  30. package/dist/llmBridge.d.ts.map +1 -0
  31. package/dist/llmBridge.js +151 -0
  32. package/dist/lp.d.ts +31 -0
  33. package/dist/lp.d.ts.map +1 -0
  34. package/dist/lp.js +116 -0
  35. package/dist/modeManager.d.ts +30 -0
  36. package/dist/modeManager.d.ts.map +1 -0
  37. package/dist/modeManager.js +81 -0
  38. package/dist/netconfig.d.ts +11 -0
  39. package/dist/netconfig.d.ts.map +1 -0
  40. package/dist/netconfig.js +39 -0
  41. package/dist/offer.d.ts +70 -0
  42. package/dist/offer.d.ts.map +1 -0
  43. package/dist/offer.js +84 -0
  44. package/dist/ollama.d.ts +53 -0
  45. package/dist/ollama.d.ts.map +1 -0
  46. package/dist/ollama.js +162 -0
  47. package/dist/p2pNode.d.ts +50 -0
  48. package/dist/p2pNode.d.ts.map +1 -0
  49. package/dist/p2pNode.js +450 -0
  50. package/dist/paywall.d.ts +30 -0
  51. package/dist/paywall.d.ts.map +1 -0
  52. package/dist/paywall.js +84 -0
  53. package/dist/publishOptIn.d.ts +15 -0
  54. package/dist/publishOptIn.d.ts.map +1 -0
  55. package/dist/publishOptIn.js +34 -0
  56. package/dist/receipts.d.ts +61 -0
  57. package/dist/receipts.d.ts.map +1 -0
  58. package/dist/receipts.js +135 -0
  59. package/dist/registry.d.ts +19 -0
  60. package/dist/registry.d.ts.map +1 -0
  61. package/dist/registry.js +50 -0
  62. package/dist/rpc.d.ts +24 -0
  63. package/dist/rpc.d.ts.map +1 -0
  64. package/dist/rpc.js +143 -0
  65. package/dist/status.d.ts +56 -0
  66. package/dist/status.d.ts.map +1 -0
  67. package/dist/status.js +354 -0
  68. package/dist/userSession.d.ts +64 -0
  69. package/dist/userSession.d.ts.map +1 -0
  70. package/dist/userSession.js +125 -0
  71. package/dist/workerSession.d.ts +67 -0
  72. package/dist/workerSession.d.ts.map +1 -0
  73. package/dist/workerSession.js +65 -0
  74. package/package.json +56 -3
  75. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GenKaKu Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,46 @@
1
+ import type { Libp2p } from 'libp2p';
2
+ import type { Hex } from 'viem';
3
+ import type { ModeManager, RoleMode } from './modeManager.ts';
4
+ import type { NetEnvironment } from './netconfig.ts';
5
+ import { toWire } from './offer.ts';
6
+ /** JSON served by GET /genkaku/status and rendered by GET /genkaku. */
7
+ export type BridgeStatus = {
8
+ role: string;
9
+ workers: string[];
10
+ wallet: string;
11
+ offers: Array<{
12
+ addr: string;
13
+ offer: ReturnType<typeof toWire>;
14
+ }>;
15
+ url: string;
16
+ };
17
+ export type BridgeOptions = {
18
+ workers: string[];
19
+ port: number;
20
+ env: NetEnvironment;
21
+ modeManager: ModeManager;
22
+ getNode: () => Promise<Libp2p>;
23
+ userKey: () => Hex;
24
+ /** settle real escrows (Base Sepolia only); false = free/dev sessions */
25
+ settle: boolean;
26
+ log: (line: string) => void;
27
+ /** optional extra fields merged over the base status JSON (dashboard extension hook) */
28
+ status?: (base: BridgeStatus) => Record<string, unknown> | Promise<Record<string, unknown>>;
29
+ /** optional live mode switch from POST /genkaku/mode */
30
+ onMode?: (mode: RoleMode) => void | Promise<void>;
31
+ /** P4.9h: derived credit per worker wallet (escrow events, not self-report).
32
+ * Absent = offers keep whatever the worker claimed. */
33
+ credit?: (wallets: string[]) => Map<string, {
34
+ score: number;
35
+ sessions: number;
36
+ }> | Promise<Map<string, {
37
+ score: number;
38
+ sessions: number;
39
+ }>>;
40
+ };
41
+ export type BridgeHandle = {
42
+ url: string;
43
+ close: () => Promise<void>;
44
+ };
45
+ export declare function startOpenAiBridge(opts: BridgeOptions): Promise<BridgeHandle>;
46
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACpC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAA;AAa/B,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAgB,MAAM,EAAc,MAAM,YAAY,CAAA;AAI7D,uEAAuE;AACvE,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,UAAU,CAAC,OAAO,MAAM,CAAC,CAAA;KAAE,CAAC,CAAA;IACjE,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,cAAc,CAAA;IACnB,WAAW,EAAE,WAAW,CAAA;IACxB,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IAC9B,OAAO,EAAE,MAAM,GAAG,CAAA;IAClB,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3B,wFAAwF;IACxF,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAC3F,wDAAwD;IACxD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjD;4DACwD;IACxD,MAAM,CAAC,EAAE,CACP,OAAO,EAAE,MAAM,EAAE,KACd,GAAG,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CAClH,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B,CAAA;AA8ID,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAmblF"}
package/dist/bridge.js ADDED
@@ -0,0 +1,542 @@
1
+ // GENKAKU for dsh: OpenAI-compatible bridge (user side).
2
+ // The dsh agent loop talks plain OpenAI HTTP; this bridge dials a genkaku
3
+ // worker over libp2p, meters the session with receipts, and settles the
4
+ // escrow on Base Sepolia. No provider wire code leaks into dsh.
5
+ import { appendFileSync } from 'node:fs';
6
+ import { createServer } from 'node:http';
7
+ import { openEscrow, publicClientFor, readWorker, refundSession, settleSession, signReceiptPayload, walletAddress, walletClientFor, } from "./chain.js";
8
+ import { dialWorker, fetchOffers, requestSettleCosign } from "./dial.js";
9
+ import { dedupeOffers, toWire } from "./offer.js";
10
+ import { bindingOf, peerIdOf } from "./registry.js";
11
+ import { runUserSession } from "./userSession.js";
12
+ const OFFERS_TTL_MS = 10_000;
13
+ const SSE_TAIL = String.fromCharCode(10, 10);
14
+ const SSE_DONE = 'data: ' + String.fromCharCode(91, 68, 79, 78, 69, 93) + SSE_TAIL;
15
+ function textOf(content) {
16
+ if (typeof content === 'string')
17
+ return content;
18
+ if (!Array.isArray(content))
19
+ return '';
20
+ return content
21
+ .map(part => (typeof part === 'object' && part !== null && part.type === 'text'
22
+ ? String(part.text ?? '')
23
+ : ''))
24
+ .join('');
25
+ }
26
+ async function readJson(req) {
27
+ const chunks = [];
28
+ let bytes = 0;
29
+ for await (const chunk of req) {
30
+ bytes += chunk.length;
31
+ if (bytes > 4 * 1024 * 1024)
32
+ throw new Error('request body too large');
33
+ chunks.push(chunk);
34
+ }
35
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
36
+ }
37
+ function json(res, status, body) {
38
+ res.writeHead(status, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify(body));
40
+ }
41
+ /** Map a worker refusal to the HTTP status the caller should see. Payment
42
+ * problems are 402 (retry after funding); verification/connectivity are 503
43
+ * (retry shortly); anything else stays a generic failure. */
44
+ function refusalStatus(code) {
45
+ switch (code) {
46
+ case 'payment-required':
47
+ case 'unknown-escrow':
48
+ case 'wrong-worker':
49
+ case 'closed-escrow':
50
+ case 'empty-escrow':
51
+ case 'bad-escrow':
52
+ return 402;
53
+ case 'chain-unavailable':
54
+ case 'paywall-error':
55
+ return 503;
56
+ default:
57
+ // No code means the worker itself was unreachable (dial/stream error).
58
+ return 502;
59
+ }
60
+ }
61
+ function failure(res, status, message) {
62
+ json(res, status, { error: { message, type: 'invalid_request_error' } });
63
+ }
64
+ const DEBUG_LOG = '/tmp/genkaku-bridge.log';
65
+ /** Dev diagnostics: one line per request/error; logging never fails the request. */
66
+ function debugLog(line) {
67
+ try {
68
+ appendFileSync(DEBUG_LOG, new Date().toISOString() + ' ' + line + String.fromCharCode(10));
69
+ }
70
+ catch {
71
+ // best effort
72
+ }
73
+ }
74
+ /** Self-contained status dashboard: no external assets, polls /genkaku/status. */
75
+ const DASHBOARD_HTML = `<!doctype html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="utf-8">
79
+ <meta name="viewport" content="width=device-width,initial-scale=1">
80
+ <title>GENKAKU bridge</title>
81
+ <style>
82
+ body{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:24px;background:#0b0d10;color:#d7dde5}
83
+ h1{font-size:18px;margin:0 0 16px}
84
+ .m{color:#7d8794;font-weight:400}
85
+ .card{border:1px solid #222832;border-radius:8px;padding:12px;margin:12px 0;background:#11151b}
86
+ b{color:#7d8794;font-weight:500;text-transform:uppercase;font-size:11px;letter-spacing:.08em;display:block;margin-bottom:4px}
87
+ table{border-collapse:collapse;width:100%}
88
+ td,th{text-align:left;padding:4px 8px;border-bottom:1px solid #1c222b}
89
+ th{color:#7d8794;font-weight:500}
90
+ .actions{display:flex;gap:8px;flex-wrap:wrap}
91
+ button{background:#1b2330;color:#d7dde5;border:1px solid #2a323d;border-radius:4px;padding:4px 10px;cursor:pointer}
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <h1>GENKAKU <span class="m">bridge</span></h1>
96
+ <div id="root">loading...</div>
97
+ <script>
98
+ var e=function(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})};
99
+ function usdc(v){var n=Number(v);return isFinite(n)?(n/1e6).toFixed(2):e(v)}
100
+ function short(v){v=String(v||'');return v.length>12?v.slice(0,6)+'...'+v.slice(-4):v}
101
+ function card(label,value){return '<div class="card"><b>'+label+'</b>'+e(value)+'</div>'}
102
+ function switchMode(mode){
103
+ fetch('/genkaku/mode',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({mode:mode})})
104
+ .then(function(res){return res.json().then(function(body){return {ok:res.ok,body:body}})})
105
+ .then(function(r){
106
+ if(!r.ok||!r.body.ok){throw new Error((r.body.error&&r.body.error.message)||'mode switch failed')}
107
+ setTimeout(function(){location.reload()},2000);
108
+ })
109
+ .catch(function(err){alert('Mode switch failed: '+(err&&err.message?err.message:String(err)))});
110
+ }
111
+ function roleRow(role){
112
+ return '<div class="card"><b>role: '+e(role)+'</b><div class="actions">'
113
+ +'<button type="button" data-mode="user">user</button>'
114
+ +'<button type="button" data-mode="localPlusSharing">localPlusSharing</button>'
115
+ +'<button type="button" data-mode="workerDedicated">workerDedicated</button>'
116
+ +'</div></div>';
117
+ }
118
+ document.addEventListener('click',function(ev){
119
+ var t=ev.target;
120
+ if(t&&t.getAttribute&&t.getAttribute('data-mode')){switchMode(t.getAttribute('data-mode'))}
121
+ });
122
+ async function load(){
123
+ var root=document.getElementById('root');
124
+ try{
125
+ var res=await fetch('/genkaku/status',{cache:'no-store'});
126
+ var s=await res.json();
127
+ var rows=(s.offers||[]).map(function(r){var o=r.offer||{};return '<tr><td>'+e(o.model)+'</td><td>'+e(o.workerName)+'</td><td>'+usdc(o.pricePerHour)+'</td><td>'+e(o.creditScore)+'</td><td>'+e(short(o.workerWallet))+'</td><td>'+e(r.addr)+'</td></tr>'}).join('');
128
+ var table='<div class="card"><b>offers</b><table><tr><th>model</th><th>worker</th><th>USDC/hour</th><th>credit</th><th>wallet</th><th>peer</th></tr>'+(rows||'<tr><td colspan="6">no offers</td></tr>')+'</table></div>';
129
+ root.innerHTML=roleRow(s.role)+card('bridge url',s.url)+card('wallet',s.wallet)+card('workers',(s.workers||[]).join(', '))+table;
130
+ }catch(err){
131
+ root.innerHTML='<div class="card">status unavailable: '+e(err&&err.message?err.message:String(err))+'</div>';
132
+ }
133
+ }
134
+ load();
135
+ setInterval(load,5000);
136
+ </script>
137
+ </body>
138
+ </html>`;
139
+ export async function startOpenAiBridge(opts) {
140
+ const cache = new Map();
141
+ let bridgeUrl = '';
142
+ async function offersOf(addr) {
143
+ const hit = cache.get(addr);
144
+ if (hit != null && Date.now() - hit.at < OFFERS_TTL_MS)
145
+ return hit.offers;
146
+ const node = await opts.getNode();
147
+ const offers = await fetchOffers(node, addr);
148
+ cache.set(addr, { at: Date.now(), offers });
149
+ return offers;
150
+ }
151
+ async function board() {
152
+ const rows = await Promise.all(opts.workers.map(async (addr) => {
153
+ try {
154
+ return (await offersOf(addr)).map(offer => ({ addr, offer }));
155
+ }
156
+ catch (error) {
157
+ opts.log(`offers: ${addr} unreachable (${error instanceof Error ? error.message : String(error)})`);
158
+ return [];
159
+ }
160
+ }));
161
+ return dedupeOffers(rows.flat());
162
+ }
163
+ /** P4.4 close-out: ask the worker to co-sign the held max-cumulative receipt,
164
+ * then settle the escrow with that signature. Refused/failed settlements are
165
+ * logged but never fail the HTTP response — the tokens already flowed. */
166
+ async function settle(args) {
167
+ let cosign;
168
+ try {
169
+ cosign = await requestSettleCosign(args.node, args.addr, {
170
+ jobId: args.jobId,
171
+ escrowId: args.escrowId,
172
+ receiptHash: args.receiptHash,
173
+ spend: args.spend,
174
+ });
175
+ }
176
+ catch (error) {
177
+ const message = `settle ${args.jobId}: co-sign unreachable (${error instanceof Error ? error.message : String(error)})`;
178
+ debugLog(message);
179
+ opts.log(message);
180
+ return;
181
+ }
182
+ if (!cosign.ok || cosign.sig == null) {
183
+ debugLog(`settle ${args.jobId}: worker refused (${cosign.reason ?? 'no reason'})`);
184
+ opts.log(`settle ${args.jobId}: worker refused (${cosign.reason ?? 'no reason'})`);
185
+ return;
186
+ }
187
+ try {
188
+ const txHash = await settleSession(args.walletC, opts.env, {
189
+ escrowId: BigInt(args.escrowId),
190
+ receiptHash: args.receiptHash,
191
+ spend: args.spend,
192
+ workerSig: cosign.sig,
193
+ });
194
+ debugLog(`settle ${args.jobId}: escrow ${args.escrowId}, spend ${args.spend}, tx ${txHash}`);
195
+ opts.log(`settle ${args.jobId}: escrow ${args.escrowId}, spend ${args.spend}, tx ${txHash}`);
196
+ }
197
+ catch (error) {
198
+ const message = `settle ${args.jobId}: tx failed (${error instanceof Error ? error.message : String(error)})`;
199
+ debugLog(message);
200
+ opts.log(message);
201
+ }
202
+ }
203
+ const models = async (res) => {
204
+ const rows = await board();
205
+ const seen = new Set();
206
+ const data = [];
207
+ for (const { offer } of rows) {
208
+ if (seen.has(offer.model))
209
+ continue;
210
+ seen.add(offer.model);
211
+ data.push({ id: offer.model, object: 'model', owned_by: 'genkaku' });
212
+ }
213
+ json(res, 200, { object: 'list', data });
214
+ };
215
+ const status = async (res) => {
216
+ const { mode } = opts.modeManager.current();
217
+ const rows = await board();
218
+ const wallets = rows
219
+ .map(row => row.offer.workerWallet)
220
+ .filter((w) => w != null && w.startsWith('0x'));
221
+ const credits = opts.credit == null || wallets.length === 0 ? null : await opts.credit(wallets);
222
+ const offers = rows.map((row) => {
223
+ const score = row.offer.workerWallet == null ? undefined : credits?.get(row.offer.workerWallet.toLowerCase());
224
+ const offer = score == null
225
+ ? row.offer
226
+ : { ...row.offer, creditScore: score.score, sessionsServed: score.sessions, creditSource: 'chain' };
227
+ return { addr: row.addr, offer: toWire(offer) };
228
+ });
229
+ const base = {
230
+ role: mode,
231
+ workers: opts.workers,
232
+ wallet: walletAddress(opts.userKey()),
233
+ offers,
234
+ url: bridgeUrl,
235
+ };
236
+ const extra = opts.status == null ? undefined : await opts.status(base);
237
+ json(res, 200, extra == null ? base : { ...base, ...extra });
238
+ };
239
+ const dashboard = (res) => {
240
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
241
+ res.end(DASHBOARD_HTML);
242
+ };
243
+ const chat = async (req, res) => {
244
+ const body = await readJson(req);
245
+ debugLog('chat body=' + JSON.stringify(body));
246
+ const model = typeof body.model === 'string' ? body.model : '';
247
+ debugLog('chat model=' + (model === '' ? '(none)' : model));
248
+ const messages = Array.isArray(body.messages) ? body.messages : [];
249
+ const system = textOf(messages.find(m => m.role === 'system')?.content);
250
+ const prompt = messages
251
+ .filter(m => m.role === 'user')
252
+ .map(m => textOf(m.content))
253
+ .filter(text => text !== '')
254
+ .join('\n');
255
+ if (prompt === '')
256
+ return failure(res, 400, 'no user message');
257
+ const rows = await board();
258
+ const picked = rows.find(row => row.offer.model === model) ?? (rows.length === 1 ? rows[0] : undefined);
259
+ if (picked == null)
260
+ return failure(res, 404, `model "${model}" is not offered by any configured worker`);
261
+ if (picked.offer.workerWallet?.startsWith('0x')) {
262
+ const wallet = picked.offer.workerWallet;
263
+ const peerId = peerIdOf(picked.addr);
264
+ const proven = picked.offer.walletVerified === true;
265
+ try {
266
+ const record = await readWorker(publicClientFor(opts.env), opts.env, wallet);
267
+ const binding = peerId == null ? 'absent' : bindingOf(record, peerId);
268
+ if (binding === 'mismatch') {
269
+ opts.log(`registry: ${wallet} is bound to a different peer than ${picked.addr}`);
270
+ return failure(res, 403, `worker wallet ${wallet} is not bound to ${picked.addr}; refusing to start the session`);
271
+ }
272
+ // P4.9e: the hello handshake proves key possession directly. Without
273
+ // it the on-chain binding is the only evidence — and it is a claim the
274
+ // wallet made, not proof that this peer holds the key. A paid session
275
+ // (real escrow) requires one of the two.
276
+ if (binding !== 'bound' && !proven && opts.settle) {
277
+ opts.log(`identity: ${wallet} at ${picked.addr} is neither proven nor bound — refusing a paid session`);
278
+ return failure(res, 403, `could not verify that ${picked.addr} controls wallet ${wallet}`);
279
+ }
280
+ if (binding !== 'bound' && !proven) {
281
+ opts.log(`registry: ${wallet} has no active binding yet — continuing under testnet grace (unpaid)`);
282
+ debugLog('registry absent wallet=' + wallet + ' addr=' + picked.addr);
283
+ }
284
+ debugLog(`identity: wallet=${wallet} proven=${proven} binding=${binding}`);
285
+ }
286
+ catch (error) {
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ opts.log(`registry: ${wallet} check failed (${message}) — continuing under testnet grace`);
289
+ }
290
+ }
291
+ const node = await opts.getNode();
292
+ const transport = await dialWorker(node, picked.addr);
293
+ const jobId = `dsh-${Date.now().toString(36)}`;
294
+ const created = Math.floor(Date.now() / 1000);
295
+ const id = `chatcmpl-${jobId}`;
296
+ const sse = body.stream !== false;
297
+ const maxTokens = Math.min(typeof body.max_tokens === 'number' ? body.max_tokens
298
+ : typeof body.max_completion_tokens === 'number' ? body.max_completion_tokens
299
+ : 256, 96);
300
+ let escrowId = '0';
301
+ let walletC = null;
302
+ if (opts.settle && picked.offer.workerWallet?.startsWith('0x')) {
303
+ walletC = walletClientFor(opts.env, opts.userKey());
304
+ const opened = await openEscrow(publicClientFor(opts.env), walletC, opts.env, picked.offer.workerWallet, picked.offer.pricePerHour);
305
+ escrowId = opened.escrowId.toString();
306
+ }
307
+ // Headers are written lazily, on the first byte the worker actually sends:
308
+ // a paywall refusal must be an HTTP error with a JSON body, not a 200 SSE
309
+ // stream that ends with no content (P4.9d).
310
+ let streamStarted = false;
311
+ const startStream = () => {
312
+ if (!sse || streamStarted || res.destroyed)
313
+ return;
314
+ streamStarted = true;
315
+ res.writeHead(200, {
316
+ 'content-type': 'text/event-stream',
317
+ 'cache-control': 'no-cache',
318
+ connection: 'keep-alive',
319
+ });
320
+ res.write(`data: ${JSON.stringify({
321
+ id, object: 'chat.completion.chunk', created, model: picked.offer.model,
322
+ choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }],
323
+ })}\n\n`);
324
+ };
325
+ const writeChunk = (delta, finish, usage) => {
326
+ if (!sse || res.destroyed)
327
+ return;
328
+ startStream();
329
+ const chunk = {
330
+ id, object: 'chat.completion.chunk', created, model: picked.offer.model,
331
+ choices: [{ index: 0, delta, finish_reason: finish }],
332
+ };
333
+ if (usage != null)
334
+ chunk.usage = usage;
335
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
336
+ };
337
+ let receiptHash = null;
338
+ let content = '';
339
+ const abort = () => { if (!res.writableEnded)
340
+ void transport.close(); };
341
+ // res, not req: IncomingMessage 'close' fires once the body is consumed.
342
+ res.on('close', abort);
343
+ try {
344
+ const result = await runUserSession({
345
+ jobId,
346
+ offer: picked.offer,
347
+ prompt,
348
+ system: system === '' ? undefined : system,
349
+ maxTokens,
350
+ transport,
351
+ modeManager: opts.modeManager,
352
+ escrowId,
353
+ env: opts.env,
354
+ signReceipt: async (receipt) => await signReceiptPayload(opts.userKey(), receipt),
355
+ emit: (event) => {
356
+ if (event.type === 'token') {
357
+ content += event.delta;
358
+ writeChunk({ content: event.delta }, null);
359
+ }
360
+ else if (event.type === 'receipt-sent') {
361
+ receiptHash = event.receiptHash;
362
+ }
363
+ else if (event.type === 'session-error') {
364
+ opts.log(`session ${jobId}: ${event.message}`);
365
+ }
366
+ },
367
+ });
368
+ if (result.failure != null) {
369
+ // The worker refused before answering (paywall, closed escrow, …).
370
+ // Nothing has been streamed yet, so this is still a clean HTTP error.
371
+ failure(res, refusalStatus(result.failure.code), result.failure.message);
372
+ return;
373
+ }
374
+ const usage = {
375
+ prompt_tokens: Math.ceil(prompt.length / 4),
376
+ completion_tokens: result.tokens,
377
+ total_tokens: Math.ceil(prompt.length / 4) + result.tokens,
378
+ };
379
+ if (sse) {
380
+ writeChunk({}, 'stop', usage); // finish
381
+ res.write(SSE_DONE);
382
+ res.end();
383
+ }
384
+ else {
385
+ json(res, 200, {
386
+ id,
387
+ object: 'chat.completion',
388
+ created,
389
+ model: picked.offer.model,
390
+ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
391
+ usage,
392
+ });
393
+ }
394
+ if (opts.settle && walletC != null && escrowId !== '0') {
395
+ if (receiptHash != null && result.billed > 0n) {
396
+ // The commitment the money is bound to — re-derive it offline from
397
+ // the prompt/answer to audit a settle (P4.9f commit-challenge).
398
+ debugLog(`receipt ${jobId}: hash=${receiptHash} billed=${result.billed} tokens=${result.tokens}`);
399
+ await settle({
400
+ node,
401
+ addr: picked.addr,
402
+ walletC,
403
+ jobId,
404
+ escrowId,
405
+ receiptHash,
406
+ spend: result.billed,
407
+ });
408
+ }
409
+ else {
410
+ // Nothing was billed (a session shorter than one started minute).
411
+ // Return the escrow instead of leaving the user's money parked.
412
+ try {
413
+ const tx = await refundSession(walletC, opts.env, BigInt(escrowId));
414
+ debugLog(`escrow ${escrowId}: nothing billed, refunded (tx ${tx})`);
415
+ opts.log(`escrow ${escrowId}: nothing billed, refunded (tx ${tx})`);
416
+ }
417
+ catch (error) {
418
+ const message = `escrow ${escrowId}: refund failed (${error instanceof Error ? error.message : String(error)})`;
419
+ debugLog(message);
420
+ opts.log(message);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ const code = error instanceof Error ? error.code : undefined;
428
+ debugLog('chat error: ' + message);
429
+ opts.log('session ' + jobId + ': ' + message);
430
+ // The escrow was opened before the session; if anything threw before a
431
+ // settlement, the funds must go back instead of sitting in the contract
432
+ // (a non-technical user would never find the manual refund).
433
+ if (opts.settle && walletC != null && escrowId !== '0') {
434
+ try {
435
+ const tx = await refundSession(walletC, opts.env, BigInt(escrowId));
436
+ debugLog(`escrow ${escrowId}: session failed before settle, refunded (tx ${tx})`);
437
+ opts.log(`escrow ${escrowId}: refunded after a failed session (tx ${tx})`);
438
+ }
439
+ catch (refundError) {
440
+ debugLog(`escrow ${escrowId}: refund after failure failed (${refundError instanceof Error ? refundError.message : String(refundError)})`);
441
+ }
442
+ }
443
+ if (!res.headersSent) {
444
+ failure(res, refusalStatus(code), message);
445
+ }
446
+ else if (!res.writableEnded) {
447
+ res.end();
448
+ }
449
+ }
450
+ finally {
451
+ res.off('close', abort);
452
+ }
453
+ };
454
+ const server = createServer((req, res) => {
455
+ void (async () => {
456
+ try {
457
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
458
+ if (req.method === 'GET' || req.method === 'POST' || req.method === 'OPTIONS') {
459
+ res.setHeader('access-control-allow-origin', '*');
460
+ res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
461
+ res.setHeader('access-control-allow-headers', 'content-type');
462
+ }
463
+ if (req.method === 'OPTIONS') {
464
+ res.writeHead(204);
465
+ res.end();
466
+ return;
467
+ }
468
+ if (req.method === 'POST' && (path === '/genkaku/mode' || path === '/genkaku/mode/')) {
469
+ if (opts.onMode == null) {
470
+ failure(res, 501, 'mode switching is not enabled');
471
+ return;
472
+ }
473
+ let body;
474
+ try {
475
+ body = await readJson(req);
476
+ }
477
+ catch {
478
+ failure(res, 400, 'invalid JSON body');
479
+ return;
480
+ }
481
+ const mode = body.mode;
482
+ if (mode !== 'user' && mode !== 'localPlusSharing' && mode !== 'workerDedicated') {
483
+ failure(res, 400, 'mode must be one of user, localPlusSharing, workerDedicated');
484
+ return;
485
+ }
486
+ await opts.onMode(mode);
487
+ json(res, 200, { ok: true });
488
+ return;
489
+ }
490
+ if (req.method === 'GET' && (path === '/genkaku/status' || path === '/genkaku/status/')) {
491
+ await status(res);
492
+ return;
493
+ }
494
+ if (req.method === 'GET' && (path === '/genkaku' || path === '/genkaku/')) {
495
+ dashboard(res);
496
+ return;
497
+ }
498
+ if (req.method === 'GET' && (path === '/v1/models' || path === '/models')) {
499
+ await models(res);
500
+ return;
501
+ }
502
+ if (req.method === 'POST' && (path === '/v1/chat/completions' || path === '/chat/completions')) {
503
+ await chat(req, res);
504
+ return;
505
+ }
506
+ failure(res, 404, 'unknown route: ' + (req.method ?? 'GET') + ' ' + path);
507
+ }
508
+ catch (error) {
509
+ const message = error instanceof Error ? error.message : String(error);
510
+ debugLog('bridge error: ' + message);
511
+ opts.log('bridge: ' + message);
512
+ if (!res.headersSent) {
513
+ failure(res, 500, message);
514
+ }
515
+ else if (!res.writableEnded) {
516
+ res.end();
517
+ }
518
+ }
519
+ })();
520
+ });
521
+ await new Promise((resolve, reject) => {
522
+ const onError = (error) => reject(error);
523
+ server.once('error', onError);
524
+ server.listen(opts.port, '127.0.0.1', () => {
525
+ server.off('error', onError);
526
+ resolve();
527
+ });
528
+ });
529
+ const bound = server.address();
530
+ const port = typeof bound === 'object' && bound !== null ? bound.port : opts.port;
531
+ bridgeUrl = 'http://127.0.0.1:' + port + '/v1';
532
+ return {
533
+ url: bridgeUrl,
534
+ close: async () => {
535
+ await new Promise((resolve, reject) => {
536
+ server.close(error => (error == null ? resolve() : reject(error)));
537
+ server.closeAllConnections();
538
+ });
539
+ },
540
+ };
541
+ }
542
+ //# sourceMappingURL=bridge.js.map