@genkaku/protocol 0.0.0-stage → 0.1.1

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 (87) 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 +609 -0
  5. package/dist/capabilities.d.ts +50 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +66 -0
  8. package/dist/capabilities.test.d.ts +2 -0
  9. package/dist/capabilities.test.d.ts.map +1 -0
  10. package/dist/capabilities.test.js +39 -0
  11. package/dist/chain.d.ts +496 -0
  12. package/dist/chain.d.ts.map +1 -0
  13. package/dist/chain.js +763 -0
  14. package/dist/credit.d.ts +35 -0
  15. package/dist/credit.d.ts.map +1 -0
  16. package/dist/credit.js +112 -0
  17. package/dist/dial.d.ts +51 -0
  18. package/dist/dial.d.ts.map +1 -0
  19. package/dist/dial.js +287 -0
  20. package/dist/directory.d.ts +39 -0
  21. package/dist/directory.d.ts.map +1 -0
  22. package/dist/directory.js +149 -0
  23. package/dist/discovery.d.ts +20 -0
  24. package/dist/discovery.d.ts.map +1 -0
  25. package/dist/discovery.js +58 -0
  26. package/dist/hello.d.ts +14 -0
  27. package/dist/hello.d.ts.map +1 -0
  28. package/dist/hello.js +42 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +32 -0
  32. package/dist/keys.d.ts +30 -0
  33. package/dist/keys.d.ts.map +1 -0
  34. package/dist/keys.js +126 -0
  35. package/dist/llmBridge.d.ts +100 -0
  36. package/dist/llmBridge.d.ts.map +1 -0
  37. package/dist/llmBridge.js +284 -0
  38. package/dist/lp.d.ts +31 -0
  39. package/dist/lp.d.ts.map +1 -0
  40. package/dist/lp.js +116 -0
  41. package/dist/modeManager.d.ts +30 -0
  42. package/dist/modeManager.d.ts.map +1 -0
  43. package/dist/modeManager.js +81 -0
  44. package/dist/netconfig.d.ts +11 -0
  45. package/dist/netconfig.d.ts.map +1 -0
  46. package/dist/netconfig.js +39 -0
  47. package/dist/offer.d.ts +89 -0
  48. package/dist/offer.d.ts.map +1 -0
  49. package/dist/offer.js +86 -0
  50. package/dist/offer.test.d.ts +2 -0
  51. package/dist/offer.test.d.ts.map +1 -0
  52. package/dist/offer.test.js +33 -0
  53. package/dist/ollama.d.ts +53 -0
  54. package/dist/ollama.d.ts.map +1 -0
  55. package/dist/ollama.js +162 -0
  56. package/dist/p2pNode.d.ts +52 -0
  57. package/dist/p2pNode.d.ts.map +1 -0
  58. package/dist/p2pNode.js +454 -0
  59. package/dist/paywall.d.ts +30 -0
  60. package/dist/paywall.d.ts.map +1 -0
  61. package/dist/paywall.js +84 -0
  62. package/dist/publishOptIn.d.ts +15 -0
  63. package/dist/publishOptIn.d.ts.map +1 -0
  64. package/dist/publishOptIn.js +34 -0
  65. package/dist/receipts.d.ts +61 -0
  66. package/dist/receipts.d.ts.map +1 -0
  67. package/dist/receipts.js +135 -0
  68. package/dist/registry.d.ts +19 -0
  69. package/dist/registry.d.ts.map +1 -0
  70. package/dist/registry.js +50 -0
  71. package/dist/rpc.d.ts +24 -0
  72. package/dist/rpc.d.ts.map +1 -0
  73. package/dist/rpc.js +143 -0
  74. package/dist/status.d.ts +56 -0
  75. package/dist/status.d.ts.map +1 -0
  76. package/dist/status.js +354 -0
  77. package/dist/userSession.d.ts +77 -0
  78. package/dist/userSession.d.ts.map +1 -0
  79. package/dist/userSession.js +132 -0
  80. package/dist/workerSession.d.ts +87 -0
  81. package/dist/workerSession.d.ts.map +1 -0
  82. package/dist/workerSession.js +97 -0
  83. package/dist/workerSession.test.d.ts +2 -0
  84. package/dist/workerSession.test.d.ts.map +1 -0
  85. package/dist/workerSession.test.js +49 -0
  86. package/package.json +56 -3
  87. package/README.md +0 -3
package/dist/bridge.js ADDED
@@ -0,0 +1,609 @@
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
+ /** Ceiling for one request's completion length; the hourly quote bounds cost. */
14
+ const MAX_COMPLETION_TOKENS = 4096;
15
+ const SSE_TAIL = String.fromCharCode(10, 10);
16
+ const SSE_DONE = 'data: ' + String.fromCharCode(91, 68, 79, 78, 69, 93) + SSE_TAIL;
17
+ function textOf(content) {
18
+ if (typeof content === 'string')
19
+ return content;
20
+ if (!Array.isArray(content))
21
+ return '';
22
+ return content
23
+ .map(part => (typeof part === 'object' && part !== null && part.type === 'text'
24
+ ? String(part.text ?? '')
25
+ : ''))
26
+ .join('');
27
+ }
28
+ async function readJson(req) {
29
+ const chunks = [];
30
+ let bytes = 0;
31
+ for await (const chunk of req) {
32
+ bytes += chunk.length;
33
+ if (bytes > 4 * 1024 * 1024)
34
+ throw new Error('request body too large');
35
+ chunks.push(chunk);
36
+ }
37
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
38
+ }
39
+ function json(res, status, body) {
40
+ res.writeHead(status, { 'content-type': 'application/json' });
41
+ res.end(JSON.stringify(body));
42
+ }
43
+ /** Map a worker refusal to the HTTP status the caller should see. Payment
44
+ * problems are 402 (retry after funding); verification/connectivity are 503
45
+ * (retry shortly); anything else stays a generic failure. */
46
+ function refusalStatus(code) {
47
+ switch (code) {
48
+ case 'payment-required':
49
+ case 'unknown-escrow':
50
+ case 'wrong-worker':
51
+ case 'closed-escrow':
52
+ case 'empty-escrow':
53
+ case 'bad-escrow':
54
+ return 402;
55
+ case 'chain-unavailable':
56
+ case 'paywall-error':
57
+ return 503;
58
+ default:
59
+ // No code means the worker itself was unreachable (dial/stream error).
60
+ return 502;
61
+ }
62
+ }
63
+ function failure(res, status, message) {
64
+ json(res, status, { error: { message, type: 'invalid_request_error' } });
65
+ }
66
+ const DEBUG_LOG = '/tmp/genkaku-bridge.log';
67
+ /** Dev diagnostics: one line per request/error; logging never fails the request. */
68
+ function debugLog(line) {
69
+ try {
70
+ appendFileSync(DEBUG_LOG, new Date().toISOString() + ' ' + line + String.fromCharCode(10));
71
+ }
72
+ catch {
73
+ // best effort
74
+ }
75
+ }
76
+ /** Self-contained status dashboard: no external assets, polls /genkaku/status. */
77
+ const DASHBOARD_HTML = `<!doctype html>
78
+ <html lang="en">
79
+ <head>
80
+ <meta charset="utf-8">
81
+ <meta name="viewport" content="width=device-width,initial-scale=1">
82
+ <title>GENKAKU bridge</title>
83
+ <style>
84
+ body{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:24px;background:#0b0d10;color:#d7dde5}
85
+ h1{font-size:18px;margin:0 0 16px}
86
+ .m{color:#7d8794;font-weight:400}
87
+ .card{border:1px solid #222832;border-radius:8px;padding:12px;margin:12px 0;background:#11151b}
88
+ b{color:#7d8794;font-weight:500;text-transform:uppercase;font-size:11px;letter-spacing:.08em;display:block;margin-bottom:4px}
89
+ table{border-collapse:collapse;width:100%}
90
+ td,th{text-align:left;padding:4px 8px;border-bottom:1px solid #1c222b}
91
+ th{color:#7d8794;font-weight:500}
92
+ .actions{display:flex;gap:8px;flex-wrap:wrap}
93
+ button{background:#1b2330;color:#d7dde5;border:1px solid #2a323d;border-radius:4px;padding:4px 10px;cursor:pointer}
94
+ </style>
95
+ </head>
96
+ <body>
97
+ <h1>GENKAKU <span class="m">bridge</span></h1>
98
+ <div id="root">loading...</div>
99
+ <script>
100
+ var e=function(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})};
101
+ function usdc(v){var n=Number(v);return isFinite(n)?(n/1e6).toFixed(2):e(v)}
102
+ function short(v){v=String(v||'');return v.length>12?v.slice(0,6)+'...'+v.slice(-4):v}
103
+ function card(label,value){return '<div class="card"><b>'+label+'</b>'+e(value)+'</div>'}
104
+ function switchMode(mode){
105
+ fetch('/genkaku/mode',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({mode:mode})})
106
+ .then(function(res){return res.json().then(function(body){return {ok:res.ok,body:body}})})
107
+ .then(function(r){
108
+ if(!r.ok||!r.body.ok){throw new Error((r.body.error&&r.body.error.message)||'mode switch failed')}
109
+ setTimeout(function(){location.reload()},2000);
110
+ })
111
+ .catch(function(err){alert('Mode switch failed: '+(err&&err.message?err.message:String(err)))});
112
+ }
113
+ function roleRow(role){
114
+ return '<div class="card"><b>role: '+e(role)+'</b><div class="actions">'
115
+ +'<button type="button" data-mode="user">user</button>'
116
+ +'<button type="button" data-mode="localPlusSharing">localPlusSharing</button>'
117
+ +'<button type="button" data-mode="workerDedicated">workerDedicated</button>'
118
+ +'</div></div>';
119
+ }
120
+ document.addEventListener('click',function(ev){
121
+ var t=ev.target;
122
+ if(t&&t.getAttribute&&t.getAttribute('data-mode')){switchMode(t.getAttribute('data-mode'))}
123
+ });
124
+ async function load(){
125
+ var root=document.getElementById('root');
126
+ try{
127
+ var res=await fetch('/genkaku/status',{cache:'no-store'});
128
+ var s=await res.json();
129
+ 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('');
130
+ 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>';
131
+ root.innerHTML=roleRow(s.role)+card('bridge url',s.url)+card('wallet',s.wallet)+card('workers',(s.workers||[]).join(', '))+table;
132
+ }catch(err){
133
+ root.innerHTML='<div class="card">status unavailable: '+e(err&&err.message?err.message:String(err))+'</div>';
134
+ }
135
+ }
136
+ load();
137
+ setInterval(load,5000);
138
+ </script>
139
+ </body>
140
+ </html>`;
141
+ export async function startOpenAiBridge(opts) {
142
+ const cache = new Map();
143
+ let bridgeUrl = '';
144
+ async function offersOf(addr) {
145
+ const hit = cache.get(addr);
146
+ if (hit != null && Date.now() - hit.at < OFFERS_TTL_MS)
147
+ return hit.offers;
148
+ const node = await opts.getNode();
149
+ const offers = await fetchOffers(node, addr);
150
+ cache.set(addr, { at: Date.now(), offers });
151
+ return offers;
152
+ }
153
+ async function board() {
154
+ const rows = await Promise.all(opts.workers.map(async (addr) => {
155
+ try {
156
+ return (await offersOf(addr)).map(offer => ({ addr, offer }));
157
+ }
158
+ catch (error) {
159
+ opts.log(`offers: ${addr} unreachable (${error instanceof Error ? error.message : String(error)})`);
160
+ return [];
161
+ }
162
+ }));
163
+ return dedupeOffers(rows.flat());
164
+ }
165
+ /** P4.4 close-out: ask the worker to co-sign the held max-cumulative receipt,
166
+ * then settle the escrow with that signature. Refused/failed settlements are
167
+ * logged but never fail the HTTP response — the tokens already flowed. */
168
+ async function settle(args) {
169
+ let cosign;
170
+ try {
171
+ cosign = await requestSettleCosign(args.node, args.addr, {
172
+ jobId: args.jobId,
173
+ escrowId: args.escrowId,
174
+ receiptHash: args.receiptHash,
175
+ spend: args.spend,
176
+ });
177
+ }
178
+ catch (error) {
179
+ const message = `settle ${args.jobId}: co-sign unreachable (${error instanceof Error ? error.message : String(error)})`;
180
+ debugLog(message);
181
+ opts.log(message);
182
+ return;
183
+ }
184
+ if (!cosign.ok || cosign.sig == null) {
185
+ debugLog(`settle ${args.jobId}: worker refused (${cosign.reason ?? 'no reason'})`);
186
+ opts.log(`settle ${args.jobId}: worker refused (${cosign.reason ?? 'no reason'})`);
187
+ return;
188
+ }
189
+ try {
190
+ const txHash = await settleSession(args.walletC, opts.env, {
191
+ escrowId: BigInt(args.escrowId),
192
+ receiptHash: args.receiptHash,
193
+ spend: args.spend,
194
+ workerSig: cosign.sig,
195
+ });
196
+ debugLog(`settle ${args.jobId}: escrow ${args.escrowId}, spend ${args.spend}, tx ${txHash}`);
197
+ opts.log(`settle ${args.jobId}: escrow ${args.escrowId}, spend ${args.spend}, tx ${txHash}`);
198
+ }
199
+ catch (error) {
200
+ const message = `settle ${args.jobId}: tx failed (${error instanceof Error ? error.message : String(error)})`;
201
+ debugLog(message);
202
+ opts.log(message);
203
+ }
204
+ }
205
+ const models = async (res) => {
206
+ const rows = await board();
207
+ const seen = new Map();
208
+ for (const { offer } of rows) {
209
+ if (seen.has(offer.model))
210
+ continue;
211
+ // A v0 row carries no capability facts, so it is chat-only by definition.
212
+ seen.set(offer.model, {
213
+ tier: offer.tier ?? 'chat',
214
+ capabilities: offer.capabilities ?? [],
215
+ ...(offer.parameterSize === undefined ? {} : { parameterSize: offer.parameterSize }),
216
+ });
217
+ }
218
+ const data = [...seen.entries()].map(([id, facts]) => ({
219
+ id,
220
+ object: 'model',
221
+ created: 0,
222
+ owned_by: 'genkaku',
223
+ // Non-standard, ignored by OpenAI clients and read by our selector.
224
+ genkaku: facts,
225
+ }));
226
+ json(res, 200, { object: 'list', data });
227
+ };
228
+ const status = async (res) => {
229
+ const { mode } = opts.modeManager.current();
230
+ const rows = await board();
231
+ const wallets = rows
232
+ .map(row => row.offer.workerWallet)
233
+ .filter((w) => w != null && w.startsWith('0x'));
234
+ const credits = opts.credit == null || wallets.length === 0 ? null : await opts.credit(wallets);
235
+ const offers = rows.map((row) => {
236
+ const score = row.offer.workerWallet == null ? undefined : credits?.get(row.offer.workerWallet.toLowerCase());
237
+ const offer = score == null
238
+ ? row.offer
239
+ : { ...row.offer, creditScore: score.score, sessionsServed: score.sessions, creditSource: 'chain' };
240
+ return { addr: row.addr, offer: toWire(offer) };
241
+ });
242
+ const base = {
243
+ role: mode,
244
+ workers: opts.workers,
245
+ wallet: walletAddress(opts.userKey()),
246
+ offers,
247
+ url: bridgeUrl,
248
+ };
249
+ const extra = opts.status == null ? undefined : await opts.status(base);
250
+ json(res, 200, extra == null ? base : { ...base, ...extra });
251
+ };
252
+ const dashboard = (res) => {
253
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
254
+ res.end(DASHBOARD_HTML);
255
+ };
256
+ const chat = async (req, res) => {
257
+ const body = await readJson(req);
258
+ debugLog('chat body=' + JSON.stringify(body));
259
+ const model = typeof body.model === 'string' ? body.model : '';
260
+ debugLog('chat model=' + (model === '' ? '(none)' : model));
261
+ const messages = Array.isArray(body.messages) ? body.messages : [];
262
+ const system = textOf(messages.find(m => m.role === 'system')?.content);
263
+ const prompt = messages
264
+ .filter(m => m.role === 'user')
265
+ .map(m => textOf(m.content))
266
+ .filter(text => text !== '')
267
+ .join('\n');
268
+ if (prompt === '')
269
+ return failure(res, 400, 'no user message');
270
+ // A request that carries tool schemas, or whose history already contains
271
+ // tool calls/results, needs the chat revision: the single-prompt form would
272
+ // silently drop the very thing an agent turn depends on.
273
+ const needsChat = Array.isArray(body.tools) && body.tools.length > 0
274
+ || messages.some(m => m.role === 'tool' || Array.isArray(m.tool_calls));
275
+ const conversation = needsChat
276
+ ? messages
277
+ .filter(m => m.role === 'system' || m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
278
+ .map(m => ({
279
+ role: m.role,
280
+ content: textOf(m.content),
281
+ ...(Array.isArray(m.tool_calls) ? { tool_calls: m.tool_calls } : {}),
282
+ ...(typeof m.tool_call_id === 'string' ? { tool_call_id: m.tool_call_id } : {}),
283
+ }))
284
+ : undefined;
285
+ const rows = await board();
286
+ const picked = rows.find(row => row.offer.model === model) ?? (rows.length === 1 ? rows[0] : undefined);
287
+ if (picked == null)
288
+ return failure(res, 404, `model "${model}" is not offered by any configured worker`);
289
+ if (picked.offer.workerWallet?.startsWith('0x')) {
290
+ const wallet = picked.offer.workerWallet;
291
+ const peerId = peerIdOf(picked.addr);
292
+ const proven = picked.offer.walletVerified === true;
293
+ try {
294
+ const record = await readWorker(publicClientFor(opts.env), opts.env, wallet);
295
+ const binding = peerId == null ? 'absent' : bindingOf(record, peerId);
296
+ if (binding === 'mismatch') {
297
+ opts.log(`registry: ${wallet} is bound to a different peer than ${picked.addr}`);
298
+ return failure(res, 403, `worker wallet ${wallet} is not bound to ${picked.addr}; refusing to start the session`);
299
+ }
300
+ // P4.9e: the hello handshake proves key possession directly. Without
301
+ // it the on-chain binding is the only evidence — and it is a claim the
302
+ // wallet made, not proof that this peer holds the key. A paid session
303
+ // (real escrow) requires one of the two.
304
+ if (binding !== 'bound' && !proven && opts.settle) {
305
+ opts.log(`identity: ${wallet} at ${picked.addr} is neither proven nor bound — refusing a paid session`);
306
+ return failure(res, 403, `could not verify that ${picked.addr} controls wallet ${wallet}`);
307
+ }
308
+ if (binding !== 'bound' && !proven) {
309
+ opts.log(`registry: ${wallet} has no active binding yet — continuing under testnet grace (unpaid)`);
310
+ debugLog('registry absent wallet=' + wallet + ' addr=' + picked.addr);
311
+ }
312
+ debugLog(`identity: wallet=${wallet} proven=${proven} binding=${binding}`);
313
+ }
314
+ catch (error) {
315
+ const message = error instanceof Error ? error.message : String(error);
316
+ opts.log(`registry: ${wallet} check failed (${message}) — continuing under testnet grace`);
317
+ }
318
+ }
319
+ const node = await opts.getNode();
320
+ const transport = await dialWorker(node, picked.addr);
321
+ const jobId = `dsh-${Date.now().toString(36)}`;
322
+ const created = Math.floor(Date.now() / 1000);
323
+ const id = `chatcmpl-${jobId}`;
324
+ const sse = body.stream !== false;
325
+ // Cost is bounded by the hourly quote, not by starving the completion: an
326
+ // agent needs room for a tool call and a short answer. 4096 is the ceiling
327
+ // any single request may ask for.
328
+ const maxTokens = Math.min(typeof body.max_tokens === 'number' ? body.max_tokens
329
+ : typeof body.max_completion_tokens === 'number' ? body.max_completion_tokens
330
+ : 1024, MAX_COMPLETION_TOKENS);
331
+ let escrowId = '0';
332
+ let walletC = null;
333
+ if (opts.settle && picked.offer.workerWallet?.startsWith('0x')) {
334
+ walletC = walletClientFor(opts.env, opts.userKey());
335
+ const opened = await openEscrow(publicClientFor(opts.env), walletC, opts.env, picked.offer.workerWallet, picked.offer.pricePerHour);
336
+ escrowId = opened.escrowId.toString();
337
+ }
338
+ // Headers are written lazily, on the first byte the worker actually sends:
339
+ // a paywall refusal must be an HTTP error with a JSON body, not a 200 SSE
340
+ // stream that ends with no content (P4.9d).
341
+ let streamStarted = false;
342
+ const startStream = () => {
343
+ if (!sse || streamStarted || res.destroyed)
344
+ return;
345
+ streamStarted = true;
346
+ res.writeHead(200, {
347
+ 'content-type': 'text/event-stream',
348
+ 'cache-control': 'no-cache',
349
+ connection: 'keep-alive',
350
+ });
351
+ res.write(`data: ${JSON.stringify({
352
+ id, object: 'chat.completion.chunk', created, model: picked.offer.model,
353
+ choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }],
354
+ })}\n\n`);
355
+ };
356
+ const writeChunk = (delta, finish, usage) => {
357
+ if (!sse || res.destroyed)
358
+ return;
359
+ startStream();
360
+ const chunk = {
361
+ id, object: 'chat.completion.chunk', created, model: picked.offer.model,
362
+ choices: [{ index: 0, delta, finish_reason: finish }],
363
+ };
364
+ if (usage != null)
365
+ chunk.usage = usage;
366
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
367
+ };
368
+ let receiptHash = null;
369
+ let sawToolCall = false;
370
+ const toolCalls = [];
371
+ let content = '';
372
+ const abort = () => { if (!res.writableEnded)
373
+ void transport.close(); };
374
+ // res, not req: IncomingMessage 'close' fires once the body is consumed.
375
+ res.on('close', abort);
376
+ try {
377
+ const result = await runUserSession({
378
+ jobId,
379
+ offer: picked.offer,
380
+ prompt,
381
+ system: system === '' ? undefined : system,
382
+ maxTokens,
383
+ ...(conversation === undefined ? {} : { messages: conversation }),
384
+ ...(body.tools === undefined ? {} : { tools: body.tools }),
385
+ transport,
386
+ modeManager: opts.modeManager,
387
+ escrowId,
388
+ env: opts.env,
389
+ signReceipt: async (receipt) => await signReceiptPayload(opts.userKey(), receipt),
390
+ emit: (event) => {
391
+ if (event.type === 'token') {
392
+ content += event.delta;
393
+ writeChunk({ content: event.delta }, null);
394
+ }
395
+ else if (event.type === 'tool-delta') {
396
+ sawToolCall = true;
397
+ const existing = toolCalls.find(call => call.index === event.index);
398
+ if (existing == null) {
399
+ toolCalls.push({ index: event.index, id: event.id, name: event.name, arguments: event.argumentsDelta });
400
+ }
401
+ else {
402
+ existing.arguments += event.argumentsDelta;
403
+ }
404
+ writeChunk({
405
+ tool_calls: [{
406
+ index: event.index,
407
+ id: event.id,
408
+ type: 'function',
409
+ function: { name: event.name, arguments: event.argumentsDelta },
410
+ }],
411
+ }, null);
412
+ }
413
+ else if (event.type === 'receipt-sent') {
414
+ receiptHash = event.receiptHash;
415
+ }
416
+ else if (event.type === 'session-error') {
417
+ opts.log(`session ${jobId}: ${event.message}`);
418
+ }
419
+ },
420
+ });
421
+ if (result.failure != null) {
422
+ // The worker refused before answering (paywall, closed escrow, …).
423
+ // Nothing has been streamed yet, so this is still a clean HTTP error.
424
+ failure(res, refusalStatus(result.failure.code), result.failure.message);
425
+ return;
426
+ }
427
+ const usage = {
428
+ prompt_tokens: Math.ceil(prompt.length / 4),
429
+ completion_tokens: result.tokens,
430
+ total_tokens: Math.ceil(prompt.length / 4) + result.tokens,
431
+ };
432
+ if (sse) {
433
+ writeChunk({}, sawToolCall ? 'tool_calls' : 'stop', usage); // finish
434
+ res.write(SSE_DONE);
435
+ res.end();
436
+ }
437
+ else {
438
+ json(res, 200, {
439
+ id,
440
+ object: 'chat.completion',
441
+ created,
442
+ model: picked.offer.model,
443
+ choices: [{
444
+ index: 0,
445
+ message: {
446
+ role: 'assistant',
447
+ content,
448
+ ...(toolCalls.length === 0 ? {} : {
449
+ tool_calls: toolCalls.map(call => ({
450
+ id: call.id,
451
+ type: 'function',
452
+ function: { name: call.name, arguments: call.arguments },
453
+ })),
454
+ }),
455
+ },
456
+ finish_reason: sawToolCall ? 'tool_calls' : 'stop',
457
+ }],
458
+ usage,
459
+ });
460
+ }
461
+ if (opts.settle && walletC != null && escrowId !== '0') {
462
+ if (receiptHash != null && result.billed > 0n) {
463
+ // The commitment the money is bound to — re-derive it offline from
464
+ // the prompt/answer to audit a settle (P4.9f commit-challenge).
465
+ debugLog(`receipt ${jobId}: hash=${receiptHash} billed=${result.billed} tokens=${result.tokens}`);
466
+ await settle({
467
+ node,
468
+ addr: picked.addr,
469
+ walletC,
470
+ jobId,
471
+ escrowId,
472
+ receiptHash,
473
+ spend: result.billed,
474
+ });
475
+ }
476
+ else {
477
+ // Nothing was billed (a session shorter than one started minute).
478
+ // Return the escrow instead of leaving the user's money parked.
479
+ try {
480
+ const tx = await refundSession(walletC, opts.env, BigInt(escrowId));
481
+ debugLog(`escrow ${escrowId}: nothing billed, refunded (tx ${tx})`);
482
+ opts.log(`escrow ${escrowId}: nothing billed, refunded (tx ${tx})`);
483
+ }
484
+ catch (error) {
485
+ const message = `escrow ${escrowId}: refund failed (${error instanceof Error ? error.message : String(error)})`;
486
+ debugLog(message);
487
+ opts.log(message);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ catch (error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ const code = error instanceof Error ? error.code : undefined;
495
+ debugLog('chat error: ' + message);
496
+ opts.log('session ' + jobId + ': ' + message);
497
+ // The escrow was opened before the session; if anything threw before a
498
+ // settlement, the funds must go back instead of sitting in the contract
499
+ // (a non-technical user would never find the manual refund).
500
+ if (opts.settle && walletC != null && escrowId !== '0') {
501
+ try {
502
+ const tx = await refundSession(walletC, opts.env, BigInt(escrowId));
503
+ debugLog(`escrow ${escrowId}: session failed before settle, refunded (tx ${tx})`);
504
+ opts.log(`escrow ${escrowId}: refunded after a failed session (tx ${tx})`);
505
+ }
506
+ catch (refundError) {
507
+ debugLog(`escrow ${escrowId}: refund after failure failed (${refundError instanceof Error ? refundError.message : String(refundError)})`);
508
+ }
509
+ }
510
+ if (!res.headersSent) {
511
+ failure(res, refusalStatus(code), message);
512
+ }
513
+ else if (!res.writableEnded) {
514
+ res.end();
515
+ }
516
+ }
517
+ finally {
518
+ res.off('close', abort);
519
+ }
520
+ };
521
+ const server = createServer((req, res) => {
522
+ void (async () => {
523
+ try {
524
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
525
+ if (req.method === 'GET' || req.method === 'POST' || req.method === 'OPTIONS') {
526
+ res.setHeader('access-control-allow-origin', '*');
527
+ res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
528
+ res.setHeader('access-control-allow-headers', 'content-type');
529
+ }
530
+ if (req.method === 'OPTIONS') {
531
+ res.writeHead(204);
532
+ res.end();
533
+ return;
534
+ }
535
+ if (req.method === 'POST' && (path === '/genkaku/mode' || path === '/genkaku/mode/')) {
536
+ if (opts.onMode == null) {
537
+ failure(res, 501, 'mode switching is not enabled');
538
+ return;
539
+ }
540
+ let body;
541
+ try {
542
+ body = await readJson(req);
543
+ }
544
+ catch {
545
+ failure(res, 400, 'invalid JSON body');
546
+ return;
547
+ }
548
+ const mode = body.mode;
549
+ if (mode !== 'user' && mode !== 'localPlusSharing' && mode !== 'workerDedicated') {
550
+ failure(res, 400, 'mode must be one of user, localPlusSharing, workerDedicated');
551
+ return;
552
+ }
553
+ await opts.onMode(mode);
554
+ json(res, 200, { ok: true });
555
+ return;
556
+ }
557
+ if (req.method === 'GET' && (path === '/genkaku/status' || path === '/genkaku/status/')) {
558
+ await status(res);
559
+ return;
560
+ }
561
+ if (req.method === 'GET' && (path === '/genkaku' || path === '/genkaku/')) {
562
+ dashboard(res);
563
+ return;
564
+ }
565
+ if (req.method === 'GET' && (path === '/v1/models' || path === '/models')) {
566
+ await models(res);
567
+ return;
568
+ }
569
+ if (req.method === 'POST' && (path === '/v1/chat/completions' || path === '/chat/completions')) {
570
+ await chat(req, res);
571
+ return;
572
+ }
573
+ failure(res, 404, 'unknown route: ' + (req.method ?? 'GET') + ' ' + path);
574
+ }
575
+ catch (error) {
576
+ const message = error instanceof Error ? error.message : String(error);
577
+ debugLog('bridge error: ' + message);
578
+ opts.log('bridge: ' + message);
579
+ if (!res.headersSent) {
580
+ failure(res, 500, message);
581
+ }
582
+ else if (!res.writableEnded) {
583
+ res.end();
584
+ }
585
+ }
586
+ })();
587
+ });
588
+ await new Promise((resolve, reject) => {
589
+ const onError = (error) => reject(error);
590
+ server.once('error', onError);
591
+ server.listen(opts.port, '127.0.0.1', () => {
592
+ server.off('error', onError);
593
+ resolve();
594
+ });
595
+ });
596
+ const bound = server.address();
597
+ const port = typeof bound === 'object' && bound !== null ? bound.port : opts.port;
598
+ bridgeUrl = 'http://127.0.0.1:' + port + '/v1';
599
+ return {
600
+ url: bridgeUrl,
601
+ close: async () => {
602
+ await new Promise((resolve, reject) => {
603
+ server.close(error => (error == null ? resolve() : reject(error)));
604
+ server.closeAllConnections();
605
+ });
606
+ },
607
+ };
608
+ }
609
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a shared model can actually do.
3
+ *
4
+ * Ollama reports a `capabilities` list per model (`completion`, `tools`,
5
+ * `thinking`, `vision`, …). A model that lists `tools` accepts the field, which
6
+ * is not the same as calling tools well: a 3B model usually emits malformed
7
+ * calls. So the tier combines the capability with the parameter count, and the
8
+ * UI labels only the bottom tier. Nothing here blocks a model from being
9
+ * offered — the marketplace marks it instead.
10
+ */
11
+ export type ModelTier = 'agent' | 'light' | 'chat';
12
+ export type ModelProfile = {
13
+ model: string;
14
+ /** raw capability list from Ollama, e.g. ['completion', 'tools'] */
15
+ capabilities: string[];
16
+ /** raw `details.parameter_size`, e.g. '9.0B' */
17
+ parameterSize?: string;
18
+ /** billions of parameters, parsed from `parameterSize` when possible */
19
+ parametersB?: number;
20
+ tier: ModelTier;
21
+ };
22
+ /** Size a model needs before its tool calls are worth advertising to an agent. */
23
+ export declare const AGENT_MIN_BILLIONS = 9;
24
+ /** Below this a model with the `tools` capability is only a light agent. */
25
+ export declare const LIGHT_MIN_BILLIONS = 3;
26
+ /**
27
+ * Parse Ollama's `details.parameter_size` ('1.2B', '9b', '70B', '135M').
28
+ * @param size - the raw string from `/api/tags`.
29
+ * @returns billions of parameters, or undefined when the string is not a size.
30
+ */
31
+ export declare function parseParameterSize(size: string | undefined): number | undefined;
32
+ /**
33
+ * Classify a model for the marketplace.
34
+ *
35
+ * @param capabilities - Ollama's capability list.
36
+ * @param parametersB - billions of parameters, when known.
37
+ */
38
+ export declare function tierOf(capabilities: readonly string[], parametersB?: number): ModelTier;
39
+ /** Shape returned by the probe, independent of HTTP plumbing. */
40
+ export type OllamaModelFacts = {
41
+ capabilities?: string[];
42
+ parameterSize?: string;
43
+ };
44
+ /**
45
+ * Fold `/api/show` capabilities and `/api/tags` details into one profile.
46
+ * @param model - model name as Ollama knows it.
47
+ * @param facts - whatever the two endpoints reported.
48
+ */
49
+ export declare function profileOf(model: string, facts: OllamaModelFacts): ModelProfile;
50
+ //# sourceMappingURL=capabilities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capabilities.d.ts","sourceRoot":"","sources":["../src/capabilities.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAA;AAElD,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,gDAAgD;IAChD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,wEAAwE;IACxE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,SAAS,CAAA;CAChB,CAAA;AAED,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,IAAI,CAAA;AACnC,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAO/E;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAQvF;AAED,iEAAiE;AACjE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,YAAY,CAU9E"}