@antseed/cli 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 (179) hide show
  1. package/.env.example +15 -0
  2. package/README.md +169 -0
  3. package/dist/cli/commands/balance.d.ts +3 -0
  4. package/dist/cli/commands/balance.d.ts.map +1 -0
  5. package/dist/cli/commands/balance.js +64 -0
  6. package/dist/cli/commands/balance.js.map +1 -0
  7. package/dist/cli/commands/browse.d.ts +7 -0
  8. package/dist/cli/commands/browse.d.ts.map +1 -0
  9. package/dist/cli/commands/browse.js +100 -0
  10. package/dist/cli/commands/browse.js.map +1 -0
  11. package/dist/cli/commands/config.d.ts +20 -0
  12. package/dist/cli/commands/config.d.ts.map +1 -0
  13. package/dist/cli/commands/config.js +239 -0
  14. package/dist/cli/commands/config.js.map +1 -0
  15. package/dist/cli/commands/connect.d.ts +14 -0
  16. package/dist/cli/commands/connect.d.ts.map +1 -0
  17. package/dist/cli/commands/connect.js +298 -0
  18. package/dist/cli/commands/connect.js.map +1 -0
  19. package/dist/cli/commands/connect.test.d.ts +2 -0
  20. package/dist/cli/commands/connect.test.d.ts.map +1 -0
  21. package/dist/cli/commands/connect.test.js +54 -0
  22. package/dist/cli/commands/connect.test.js.map +1 -0
  23. package/dist/cli/commands/dashboard.d.ts +6 -0
  24. package/dist/cli/commands/dashboard.d.ts.map +1 -0
  25. package/dist/cli/commands/dashboard.js +48 -0
  26. package/dist/cli/commands/dashboard.js.map +1 -0
  27. package/dist/cli/commands/deposit.d.ts +3 -0
  28. package/dist/cli/commands/deposit.d.ts.map +1 -0
  29. package/dist/cli/commands/deposit.js +48 -0
  30. package/dist/cli/commands/deposit.js.map +1 -0
  31. package/dist/cli/commands/dev.d.ts +3 -0
  32. package/dist/cli/commands/dev.d.ts.map +1 -0
  33. package/dist/cli/commands/dev.js +94 -0
  34. package/dist/cli/commands/dev.js.map +1 -0
  35. package/dist/cli/commands/init.d.ts +3 -0
  36. package/dist/cli/commands/init.d.ts.map +1 -0
  37. package/dist/cli/commands/init.js +91 -0
  38. package/dist/cli/commands/init.js.map +1 -0
  39. package/dist/cli/commands/plugin-create.d.ts +11 -0
  40. package/dist/cli/commands/plugin-create.d.ts.map +1 -0
  41. package/dist/cli/commands/plugin-create.js +201 -0
  42. package/dist/cli/commands/plugin-create.js.map +1 -0
  43. package/dist/cli/commands/plugin-create.test.d.ts +2 -0
  44. package/dist/cli/commands/plugin-create.test.d.ts.map +1 -0
  45. package/dist/cli/commands/plugin-create.test.js +53 -0
  46. package/dist/cli/commands/plugin-create.test.js.map +1 -0
  47. package/dist/cli/commands/plugin.d.ts +3 -0
  48. package/dist/cli/commands/plugin.d.ts.map +1 -0
  49. package/dist/cli/commands/plugin.js +279 -0
  50. package/dist/cli/commands/plugin.js.map +1 -0
  51. package/dist/cli/commands/plugin.test.d.ts +2 -0
  52. package/dist/cli/commands/plugin.test.d.ts.map +1 -0
  53. package/dist/cli/commands/plugin.test.js +53 -0
  54. package/dist/cli/commands/plugin.test.js.map +1 -0
  55. package/dist/cli/commands/profile.d.ts +10 -0
  56. package/dist/cli/commands/profile.d.ts.map +1 -0
  57. package/dist/cli/commands/profile.js +89 -0
  58. package/dist/cli/commands/profile.js.map +1 -0
  59. package/dist/cli/commands/seed.d.ts +11 -0
  60. package/dist/cli/commands/seed.d.ts.map +1 -0
  61. package/dist/cli/commands/seed.js +397 -0
  62. package/dist/cli/commands/seed.js.map +1 -0
  63. package/dist/cli/commands/seed.test.d.ts +2 -0
  64. package/dist/cli/commands/seed.test.d.ts.map +1 -0
  65. package/dist/cli/commands/seed.test.js +57 -0
  66. package/dist/cli/commands/seed.test.js.map +1 -0
  67. package/dist/cli/commands/status.d.ts +8 -0
  68. package/dist/cli/commands/status.d.ts.map +1 -0
  69. package/dist/cli/commands/status.js +55 -0
  70. package/dist/cli/commands/status.js.map +1 -0
  71. package/dist/cli/commands/types.d.ts +14 -0
  72. package/dist/cli/commands/types.d.ts.map +1 -0
  73. package/dist/cli/commands/types.js +41 -0
  74. package/dist/cli/commands/types.js.map +1 -0
  75. package/dist/cli/commands/withdraw.d.ts +3 -0
  76. package/dist/cli/commands/withdraw.d.ts.map +1 -0
  77. package/dist/cli/commands/withdraw.js +48 -0
  78. package/dist/cli/commands/withdraw.js.map +1 -0
  79. package/dist/cli/formatters.d.ts +29 -0
  80. package/dist/cli/formatters.d.ts.map +1 -0
  81. package/dist/cli/formatters.js +67 -0
  82. package/dist/cli/formatters.js.map +1 -0
  83. package/dist/cli/index.d.ts +3 -0
  84. package/dist/cli/index.d.ts.map +1 -0
  85. package/dist/cli/index.js +41 -0
  86. package/dist/cli/index.js.map +1 -0
  87. package/dist/cli/shutdown.d.ts +11 -0
  88. package/dist/cli/shutdown.d.ts.map +1 -0
  89. package/dist/cli/shutdown.js +34 -0
  90. package/dist/cli/shutdown.js.map +1 -0
  91. package/dist/config/defaults.d.ts +6 -0
  92. package/dist/config/defaults.d.ts.map +1 -0
  93. package/dist/config/defaults.js +48 -0
  94. package/dist/config/defaults.js.map +1 -0
  95. package/dist/config/effective.d.ts +26 -0
  96. package/dist/config/effective.d.ts.map +1 -0
  97. package/dist/config/effective.js +84 -0
  98. package/dist/config/effective.js.map +1 -0
  99. package/dist/config/effective.test.d.ts +2 -0
  100. package/dist/config/effective.test.d.ts.map +1 -0
  101. package/dist/config/effective.test.js +65 -0
  102. package/dist/config/effective.test.js.map +1 -0
  103. package/dist/config/loader.d.ts +12 -0
  104. package/dist/config/loader.d.ts.map +1 -0
  105. package/dist/config/loader.js +212 -0
  106. package/dist/config/loader.js.map +1 -0
  107. package/dist/config/loader.test.d.ts +2 -0
  108. package/dist/config/loader.test.d.ts.map +1 -0
  109. package/dist/config/loader.test.js +77 -0
  110. package/dist/config/loader.test.js.map +1 -0
  111. package/dist/config/types.d.ts +133 -0
  112. package/dist/config/types.d.ts.map +1 -0
  113. package/dist/config/types.js +2 -0
  114. package/dist/config/types.js.map +1 -0
  115. package/dist/config/validation.d.ts +10 -0
  116. package/dist/config/validation.d.ts.map +1 -0
  117. package/dist/config/validation.js +50 -0
  118. package/dist/config/validation.js.map +1 -0
  119. package/dist/env/load-env.d.ts +6 -0
  120. package/dist/env/load-env.d.ts.map +1 -0
  121. package/dist/env/load-env.js +18 -0
  122. package/dist/env/load-env.js.map +1 -0
  123. package/dist/plugins/loader.d.ts +7 -0
  124. package/dist/plugins/loader.d.ts.map +1 -0
  125. package/dist/plugins/loader.js +70 -0
  126. package/dist/plugins/loader.js.map +1 -0
  127. package/dist/plugins/manager.d.ts +11 -0
  128. package/dist/plugins/manager.d.ts.map +1 -0
  129. package/dist/plugins/manager.js +52 -0
  130. package/dist/plugins/manager.js.map +1 -0
  131. package/dist/plugins/registry.d.ts +8 -0
  132. package/dist/plugins/registry.d.ts.map +1 -0
  133. package/dist/plugins/registry.js +39 -0
  134. package/dist/plugins/registry.js.map +1 -0
  135. package/dist/proxy/buyer-proxy.d.ts +30 -0
  136. package/dist/proxy/buyer-proxy.d.ts.map +1 -0
  137. package/dist/proxy/buyer-proxy.js +488 -0
  138. package/dist/proxy/buyer-proxy.js.map +1 -0
  139. package/dist/status/node-status.d.ts +22 -0
  140. package/dist/status/node-status.d.ts.map +1 -0
  141. package/dist/status/node-status.js +83 -0
  142. package/dist/status/node-status.js.map +1 -0
  143. package/package.json +39 -0
  144. package/src/cli/commands/balance.ts +77 -0
  145. package/src/cli/commands/browse.ts +113 -0
  146. package/src/cli/commands/config.ts +271 -0
  147. package/src/cli/commands/connect.test.ts +69 -0
  148. package/src/cli/commands/connect.ts +342 -0
  149. package/src/cli/commands/dashboard.ts +59 -0
  150. package/src/cli/commands/deposit.ts +61 -0
  151. package/src/cli/commands/dev.ts +107 -0
  152. package/src/cli/commands/init.ts +99 -0
  153. package/src/cli/commands/plugin-create.test.ts +60 -0
  154. package/src/cli/commands/plugin-create.ts +230 -0
  155. package/src/cli/commands/plugin.test.ts +55 -0
  156. package/src/cli/commands/plugin.ts +295 -0
  157. package/src/cli/commands/profile.ts +95 -0
  158. package/src/cli/commands/seed.test.ts +70 -0
  159. package/src/cli/commands/seed.ts +447 -0
  160. package/src/cli/commands/status.ts +73 -0
  161. package/src/cli/commands/types.ts +56 -0
  162. package/src/cli/commands/withdraw.ts +61 -0
  163. package/src/cli/formatters.ts +64 -0
  164. package/src/cli/index.ts +46 -0
  165. package/src/cli/shutdown.ts +38 -0
  166. package/src/config/defaults.ts +49 -0
  167. package/src/config/effective.test.ts +80 -0
  168. package/src/config/effective.ts +119 -0
  169. package/src/config/loader.test.ts +95 -0
  170. package/src/config/loader.ts +251 -0
  171. package/src/config/types.ts +139 -0
  172. package/src/config/validation.ts +78 -0
  173. package/src/env/load-env.ts +20 -0
  174. package/src/plugins/loader.ts +96 -0
  175. package/src/plugins/manager.ts +66 -0
  176. package/src/plugins/registry.ts +45 -0
  177. package/src/proxy/buyer-proxy.ts +604 -0
  178. package/src/status/node-status.ts +105 -0
  179. package/tsconfig.json +9 -0
@@ -0,0 +1,604 @@
1
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+ import type { AntseedNode } from '@antseed/node'
7
+ import type { PeerInfo, SerializedHttpRequest } from '@antseed/node'
8
+
9
+ export interface BuyerProxyConfig {
10
+ port: number
11
+ node: AntseedNode
12
+ /** How long to cache discovered peers before re-querying DHT (ms). Default: 30000 */
13
+ peerCacheTtlMs?: number
14
+ }
15
+
16
+ const DAEMON_STATE_FILE = join(homedir(), '.antseed', 'daemon.state.json')
17
+
18
+ const DEBUG = () =>
19
+ ['1', 'true', 'yes', 'on'].includes((process.env['ANTSEED_DEBUG'] ?? '').trim().toLowerCase())
20
+
21
+ function log(...args: unknown[]): void {
22
+ if (DEBUG()) console.log('[proxy]', ...args)
23
+ }
24
+
25
+ type TokenUsageSummary = {
26
+ inputTokens: number
27
+ outputTokens: number
28
+ totalTokens: number
29
+ source: 'usage' | 'estimated'
30
+ }
31
+
32
+ type RoutingPricing = {
33
+ provider: string
34
+ model: string | null
35
+ inputUsdPerMillion: number | null
36
+ outputUsdPerMillion: number | null
37
+ }
38
+
39
+ type ResponseTelemetry = {
40
+ usage: TokenUsageSummary
41
+ pricing: RoutingPricing
42
+ estimatedCostUsd: number | null
43
+ }
44
+
45
+ function parseTokenCount(value: unknown): number {
46
+ const parsed = Number(value)
47
+ if (!Number.isFinite(parsed) || parsed <= 0) {
48
+ return 0
49
+ }
50
+ return Math.floor(parsed)
51
+ }
52
+
53
+ function parseUsageObject(value: unknown): { inputTokens: number; outputTokens: number; totalTokens: number } {
54
+ if (!value || typeof value !== 'object') {
55
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
56
+ }
57
+
58
+ const usage = value as Record<string, unknown>
59
+ const total = parseTokenCount(usage.totalTokens ?? usage.total_tokens ?? usage.total_token_count)
60
+ let input = parseTokenCount(
61
+ usage.inputTokens
62
+ ?? usage.input_tokens
63
+ ?? usage.promptTokens
64
+ ?? usage.prompt_tokens
65
+ ?? usage.input_token_count
66
+ ?? usage.prompt_token_count
67
+ ?? usage.cache_creation_input_tokens
68
+ ?? usage.cache_read_input_tokens,
69
+ )
70
+ let output = parseTokenCount(
71
+ usage.outputTokens
72
+ ?? usage.output_tokens
73
+ ?? usage.completionTokens
74
+ ?? usage.completion_tokens
75
+ ?? usage.output_token_count
76
+ ?? usage.completion_token_count,
77
+ )
78
+
79
+ if (total > 0) {
80
+ if (input === 0 && output === 0) {
81
+ output = total
82
+ } else if (output === 0 && input > 0 && total >= input) {
83
+ output = total - input
84
+ } else if (input === 0 && output > 0 && total >= output) {
85
+ input = total - output
86
+ }
87
+ }
88
+
89
+ return {
90
+ inputTokens: input,
91
+ outputTokens: output,
92
+ totalTokens: input + output,
93
+ }
94
+ }
95
+
96
+ function estimateTokensFromBytes(inputBytes: number, outputBytes: number): TokenUsageSummary {
97
+ const inputTokens = Math.max(1, Math.round(Math.max(0, inputBytes) / 4))
98
+ const outputTokens = Math.max(1, Math.round(Math.max(0, outputBytes) / 4))
99
+ return {
100
+ inputTokens,
101
+ outputTokens,
102
+ totalTokens: inputTokens + outputTokens,
103
+ source: 'estimated',
104
+ }
105
+ }
106
+
107
+ function parseSseUsage(body: Uint8Array): { inputTokens: number; outputTokens: number; totalTokens: number } {
108
+ const text = new TextDecoder().decode(body)
109
+ const lines = text.split('\n')
110
+ let inputTokens = 0
111
+ let outputTokens = 0
112
+ let totalTokens = 0
113
+
114
+ for (const line of lines) {
115
+ const trimmed = line.trim()
116
+ if (!trimmed.startsWith('data:')) continue
117
+
118
+ const payload = trimmed.slice(5).trim()
119
+ if (payload.length === 0 || payload === '[DONE]') continue
120
+
121
+ let parsed: Record<string, unknown>
122
+ try {
123
+ parsed = JSON.parse(payload) as Record<string, unknown>
124
+ } catch {
125
+ continue
126
+ }
127
+
128
+ const directUsage = parseUsageObject(parsed.usage)
129
+ if (directUsage.totalTokens > 0) {
130
+ inputTokens = Math.max(inputTokens, directUsage.inputTokens)
131
+ outputTokens = Math.max(outputTokens, directUsage.outputTokens)
132
+ totalTokens = Math.max(totalTokens, directUsage.totalTokens)
133
+ }
134
+
135
+ const message = parsed.message
136
+ const messageUsage = parseUsageObject(message && typeof message === 'object' ? (message as Record<string, unknown>).usage : undefined)
137
+ if (messageUsage.totalTokens > 0) {
138
+ inputTokens = Math.max(inputTokens, messageUsage.inputTokens)
139
+ outputTokens = Math.max(outputTokens, messageUsage.outputTokens)
140
+ totalTokens = Math.max(totalTokens, messageUsage.totalTokens)
141
+ }
142
+ }
143
+
144
+ if (totalTokens <= 0) {
145
+ totalTokens = inputTokens + outputTokens
146
+ }
147
+
148
+ return { inputTokens, outputTokens, totalTokens }
149
+ }
150
+
151
+ function parseJsonUsage(body: Uint8Array): { inputTokens: number; outputTokens: number; totalTokens: number } {
152
+ try {
153
+ const parsed = JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>
154
+ const direct = parseUsageObject(parsed.usage)
155
+ if (direct.totalTokens > 0) {
156
+ return direct
157
+ }
158
+
159
+ const message = parsed.message
160
+ if (message && typeof message === 'object') {
161
+ const nested = parseUsageObject((message as Record<string, unknown>).usage)
162
+ if (nested.totalTokens > 0) {
163
+ return nested
164
+ }
165
+ }
166
+
167
+ const result = parsed.result
168
+ if (result && typeof result === 'object') {
169
+ const nested = parseUsageObject((result as Record<string, unknown>).usage)
170
+ if (nested.totalTokens > 0) {
171
+ return nested
172
+ }
173
+ }
174
+
175
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
176
+ } catch {
177
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
178
+ }
179
+ }
180
+
181
+ function pickProviderForPeer(peer: PeerInfo, request: SerializedHttpRequest): string {
182
+ const explicit = request.headers['x-antseed-provider']?.trim()
183
+ if (explicit && explicit.length > 0) {
184
+ return explicit.toLowerCase()
185
+ }
186
+
187
+ if (request.path.startsWith('/v1/messages') && peer.providers.includes('anthropic')) {
188
+ return 'anthropic'
189
+ }
190
+
191
+ const first = peer.providers[0]?.trim()
192
+ if (first && first.length > 0) {
193
+ return first.toLowerCase()
194
+ }
195
+
196
+ return 'unknown'
197
+ }
198
+
199
+ function extractRequestedModel(request: SerializedHttpRequest): string | null {
200
+ const contentType = (request.headers['content-type'] ?? request.headers['Content-Type'] ?? '').toLowerCase()
201
+ if (!contentType.includes('application/json')) {
202
+ return null
203
+ }
204
+
205
+ try {
206
+ const parsed = JSON.parse(new TextDecoder().decode(request.body)) as Record<string, unknown>
207
+ const model = parsed.model
208
+ if (typeof model === 'string' && model.trim().length > 0) {
209
+ return model.trim()
210
+ }
211
+ return null
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ function resolvePeerPricing(peer: PeerInfo, provider: string, model: string | null): { inputUsdPerMillion: number | null; outputUsdPerMillion: number | null } {
218
+ const providerPricing = peer.providerPricing?.[provider]
219
+ if (providerPricing) {
220
+ if (model && providerPricing.models?.[model]) {
221
+ return {
222
+ inputUsdPerMillion: Number.isFinite(providerPricing.models[model]!.inputUsdPerMillion)
223
+ ? providerPricing.models[model]!.inputUsdPerMillion
224
+ : null,
225
+ outputUsdPerMillion: Number.isFinite(providerPricing.models[model]!.outputUsdPerMillion)
226
+ ? providerPricing.models[model]!.outputUsdPerMillion
227
+ : null,
228
+ }
229
+ }
230
+ return {
231
+ inputUsdPerMillion: Number.isFinite(providerPricing.defaults.inputUsdPerMillion)
232
+ ? providerPricing.defaults.inputUsdPerMillion
233
+ : null,
234
+ outputUsdPerMillion: Number.isFinite(providerPricing.defaults.outputUsdPerMillion)
235
+ ? providerPricing.defaults.outputUsdPerMillion
236
+ : null,
237
+ }
238
+ }
239
+
240
+ const input = peer.defaultInputUsdPerMillion
241
+ const output = peer.defaultOutputUsdPerMillion
242
+ return {
243
+ inputUsdPerMillion: Number.isFinite(input) ? input! : null,
244
+ outputUsdPerMillion: Number.isFinite(output) ? output! : null,
245
+ }
246
+ }
247
+
248
+ function computeResponseTelemetry(
249
+ request: SerializedHttpRequest,
250
+ responseHeaders: Record<string, string>,
251
+ responseBody: Uint8Array,
252
+ selectedPeer: PeerInfo,
253
+ ): ResponseTelemetry {
254
+ const provider = pickProviderForPeer(selectedPeer, request)
255
+ const model = extractRequestedModel(request)
256
+ const pricing = resolvePeerPricing(selectedPeer, provider, model)
257
+ const contentType = (responseHeaders['content-type'] ?? '').toLowerCase()
258
+
259
+ const usageFromBody = contentType.includes('text/event-stream')
260
+ ? parseSseUsage(responseBody)
261
+ : parseJsonUsage(responseBody)
262
+
263
+ let usage: TokenUsageSummary
264
+ if (usageFromBody.totalTokens > 0) {
265
+ usage = {
266
+ inputTokens: usageFromBody.inputTokens,
267
+ outputTokens: usageFromBody.outputTokens,
268
+ totalTokens: usageFromBody.totalTokens,
269
+ source: 'usage',
270
+ }
271
+ } else {
272
+ usage = estimateTokensFromBytes(request.body.length, responseBody.length)
273
+ }
274
+
275
+ let estimatedCostUsd: number | null = null
276
+ if (
277
+ pricing.inputUsdPerMillion !== null &&
278
+ pricing.outputUsdPerMillion !== null &&
279
+ Number.isFinite(pricing.inputUsdPerMillion) &&
280
+ Number.isFinite(pricing.outputUsdPerMillion)
281
+ ) {
282
+ estimatedCostUsd =
283
+ (usage.inputTokens * pricing.inputUsdPerMillion + usage.outputTokens * pricing.outputUsdPerMillion) / 1_000_000
284
+ }
285
+
286
+ return {
287
+ usage,
288
+ pricing: {
289
+ provider,
290
+ model,
291
+ inputUsdPerMillion: pricing.inputUsdPerMillion,
292
+ outputUsdPerMillion: pricing.outputUsdPerMillion,
293
+ },
294
+ estimatedCostUsd,
295
+ }
296
+ }
297
+
298
+ function attachAntseedTelemetryHeaders(
299
+ upstreamHeaders: Record<string, string>,
300
+ selectedPeer: PeerInfo,
301
+ telemetry: ResponseTelemetry,
302
+ requestId: string,
303
+ latencyMs: number,
304
+ ): Record<string, string> {
305
+ const headers: Record<string, string> = { ...upstreamHeaders }
306
+ headers['x-antseed-request-id'] = requestId
307
+ headers['x-antseed-latency-ms'] = String(Math.max(0, Math.floor(latencyMs)))
308
+ headers['x-antseed-peer-id'] = selectedPeer.peerId
309
+ if (selectedPeer.publicAddress) {
310
+ headers['x-antseed-peer-address'] = selectedPeer.publicAddress
311
+ }
312
+ if (selectedPeer.providers.length > 0) {
313
+ headers['x-antseed-peer-providers'] = selectedPeer.providers.join(',')
314
+ }
315
+ if (typeof selectedPeer.reputationScore === 'number' && Number.isFinite(selectedPeer.reputationScore)) {
316
+ headers['x-antseed-peer-reputation'] = String(selectedPeer.reputationScore)
317
+ }
318
+ if (typeof selectedPeer.trustScore === 'number' && Number.isFinite(selectedPeer.trustScore)) {
319
+ headers['x-antseed-peer-trust-score'] = String(selectedPeer.trustScore)
320
+ }
321
+ if (typeof selectedPeer.currentLoad === 'number' && Number.isFinite(selectedPeer.currentLoad)) {
322
+ headers['x-antseed-peer-current-load'] = String(selectedPeer.currentLoad)
323
+ }
324
+ if (typeof selectedPeer.maxConcurrency === 'number' && Number.isFinite(selectedPeer.maxConcurrency)) {
325
+ headers['x-antseed-peer-max-concurrency'] = String(selectedPeer.maxConcurrency)
326
+ }
327
+ headers['x-antseed-provider'] = telemetry.pricing.provider
328
+ if (telemetry.pricing.model) {
329
+ headers['x-antseed-model'] = telemetry.pricing.model
330
+ }
331
+ if (telemetry.pricing.inputUsdPerMillion !== null) {
332
+ headers['x-antseed-input-usd-per-million'] = String(telemetry.pricing.inputUsdPerMillion)
333
+ }
334
+ if (telemetry.pricing.outputUsdPerMillion !== null) {
335
+ headers['x-antseed-output-usd-per-million'] = String(telemetry.pricing.outputUsdPerMillion)
336
+ }
337
+ headers['x-antseed-token-source'] = telemetry.usage.source
338
+ headers['x-antseed-input-tokens'] = String(telemetry.usage.inputTokens)
339
+ headers['x-antseed-output-tokens'] = String(telemetry.usage.outputTokens)
340
+ headers['x-antseed-total-tokens'] = String(telemetry.usage.totalTokens)
341
+ if (telemetry.estimatedCostUsd !== null && Number.isFinite(telemetry.estimatedCostUsd)) {
342
+ headers['x-antseed-estimated-cost-usd'] = telemetry.estimatedCostUsd.toFixed(6)
343
+ }
344
+ return headers
345
+ }
346
+
347
+ /**
348
+ * Local HTTP proxy that forwards requests to P2P sellers.
349
+ *
350
+ * Tools like Claude CLI set ANTHROPIC_BASE_URL=http://localhost:8377
351
+ * and the proxy transparently routes their API calls through the
352
+ * Antseed P2P network.
353
+ */
354
+ export class BuyerProxy {
355
+ private readonly _server: Server
356
+ private readonly _node: AntseedNode
357
+ private readonly _port: number
358
+ private readonly _peerCacheTtlMs: number
359
+
360
+ private _cachedPeers: PeerInfo[] = []
361
+ private _cacheTimestamp = 0
362
+
363
+ constructor(config: BuyerProxyConfig) {
364
+ this._node = config.node
365
+ this._port = config.port
366
+ this._peerCacheTtlMs = config.peerCacheTtlMs ?? 30_000
367
+ this._server = createServer((req, res) => {
368
+ this._handleRequest(req, res).catch((err) => {
369
+ log('Unhandled error:', err)
370
+ if (!res.headersSent) {
371
+ res.writeHead(502, { 'content-type': 'text/plain' })
372
+ }
373
+ res.end(`Proxy error: ${err instanceof Error ? err.message : String(err)}`)
374
+ })
375
+ })
376
+ }
377
+
378
+ async start(): Promise<void> {
379
+ return new Promise((resolve, reject) => {
380
+ this._server.once('error', reject)
381
+ this._server.listen(this._port, '127.0.0.1', () => {
382
+ this._server.removeListener('error', reject)
383
+ resolve()
384
+ })
385
+ })
386
+ }
387
+
388
+ async stop(): Promise<void> {
389
+ return new Promise((resolve) => {
390
+ this._server.close(() => resolve())
391
+ })
392
+ }
393
+
394
+ private async _readLocalSeederFallback(): Promise<PeerInfo | null> {
395
+ try {
396
+ const raw = await readFile(DAEMON_STATE_FILE, 'utf-8')
397
+ const parsed = JSON.parse(raw) as {
398
+ state?: unknown
399
+ pid?: unknown
400
+ peerId?: unknown
401
+ signalingPort?: unknown
402
+ provider?: unknown
403
+ defaultInputUsdPerMillion?: unknown
404
+ defaultOutputUsdPerMillion?: unknown
405
+ providerPricing?: unknown
406
+ }
407
+
408
+ if (parsed.state !== 'seeding') return null
409
+ if (typeof parsed.peerId !== 'string' || !/^[0-9a-f]{64}$/i.test(parsed.peerId)) return null
410
+
411
+ const signalingPort = Number(parsed.signalingPort)
412
+ if (!Number.isFinite(signalingPort) || signalingPort <= 0 || signalingPort > 65535) return null
413
+
414
+ const pid = Number(parsed.pid)
415
+ if (Number.isFinite(pid) && pid > 0) {
416
+ try {
417
+ process.kill(Math.floor(pid), 0)
418
+ } catch {
419
+ return null
420
+ }
421
+ }
422
+
423
+ const providers = typeof parsed.provider === 'string' && parsed.provider.trim().length > 0
424
+ ? [parsed.provider.trim()]
425
+ : []
426
+ const defaultInputUsdPerMillion = Number(parsed.defaultInputUsdPerMillion)
427
+ const defaultOutputUsdPerMillion = Number(parsed.defaultOutputUsdPerMillion)
428
+ const providerPricing = parsed.providerPricing && typeof parsed.providerPricing === 'object'
429
+ ? (parsed.providerPricing as PeerInfo['providerPricing'])
430
+ : undefined
431
+
432
+ const peerId = parsed.peerId.toLowerCase()
433
+ if (this._node.peerId && this._node.peerId.toLowerCase() === peerId) {
434
+ return null
435
+ }
436
+
437
+ return {
438
+ peerId: peerId as PeerInfo['peerId'],
439
+ lastSeen: Date.now(),
440
+ publicAddress: `127.0.0.1:${Math.floor(signalingPort)}`,
441
+ providers,
442
+ defaultInputUsdPerMillion: Number.isFinite(defaultInputUsdPerMillion) ? defaultInputUsdPerMillion : 0,
443
+ defaultOutputUsdPerMillion: Number.isFinite(defaultOutputUsdPerMillion) ? defaultOutputUsdPerMillion : 0,
444
+ ...(providerPricing ? { providerPricing } : {}),
445
+ }
446
+ } catch {
447
+ return null
448
+ }
449
+ }
450
+
451
+ private async _getPeers(): Promise<PeerInfo[]> {
452
+ const now = Date.now()
453
+ if (this._cachedPeers.length > 0 && now - this._cacheTimestamp < this._peerCacheTtlMs) {
454
+ return this._cachedPeers
455
+ }
456
+
457
+ log('Discovering peers via DHT...')
458
+ let peers = await this._node.discoverPeers()
459
+ const localSeeder = await this._readLocalSeederFallback()
460
+ if (localSeeder && !peers.some((peer) => peer.peerId === localSeeder.peerId)) {
461
+ peers = [localSeeder, ...peers]
462
+ log(`Added local seeder fallback ${localSeeder.peerId.slice(0, 12)}... @ ${localSeeder.publicAddress}`)
463
+ }
464
+
465
+ if (peers.length > 0) {
466
+ this._cachedPeers = peers
467
+ this._cacheTimestamp = now
468
+ log(`Found ${peers.length} peer(s)`)
469
+ }
470
+ return peers
471
+ }
472
+
473
+ private _formatPeerSelectionDiagnostics(peers: PeerInfo[]): string {
474
+ if (peers.length === 0) {
475
+ return 'No peers discovered.'
476
+ }
477
+
478
+ const summarize = (peer: PeerInfo): string => {
479
+ const providers = peer.providers
480
+ .map((provider) => provider.trim())
481
+ .filter((provider) => provider.length > 0)
482
+ const trust = Number.isFinite(peer.trustScore) ? String(peer.trustScore) : 'n/a'
483
+ const rep = Number.isFinite(peer.reputationScore) ? String(peer.reputationScore) : 'n/a'
484
+ const onChain = Number.isFinite(peer.onChainReputation) ? String(peer.onChainReputation) : 'n/a'
485
+ const input = Number.isFinite(peer.defaultInputUsdPerMillion) ? String(peer.defaultInputUsdPerMillion) : 'n/a'
486
+ const output = Number.isFinite(peer.defaultOutputUsdPerMillion) ? String(peer.defaultOutputUsdPerMillion) : 'n/a'
487
+
488
+ return `${peer.peerId.slice(0, 8)} providers=[${providers.join(',') || 'none'}] trust=${trust} rep=${rep} onchain=${onChain} in=${input} out=${output}`
489
+ }
490
+
491
+ const samples = peers.slice(0, 5).map((peer) => summarize(peer)).join(' | ')
492
+ const suffix = peers.length > 5 ? ` (+${peers.length - 5} more)` : ''
493
+ return `Discovered ${peers.length} peer(s): ${samples}${suffix}`
494
+ }
495
+
496
+ private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
497
+ const method = req.method ?? 'GET'
498
+ const path = req.url ?? '/'
499
+
500
+ log(`${method} ${path}`)
501
+
502
+ // Collect request body
503
+ const chunks: Buffer[] = []
504
+ for await (const chunk of req) {
505
+ chunks.push(chunk as Buffer)
506
+ }
507
+ const body = Buffer.concat(chunks)
508
+
509
+ // Build serialized request
510
+ const headers: Record<string, string> = {}
511
+ for (const [key, value] of Object.entries(req.headers)) {
512
+ if (typeof value === 'string') {
513
+ headers[key] = value
514
+ } else if (Array.isArray(value)) {
515
+ headers[key] = value.join(', ')
516
+ }
517
+ }
518
+ // Remove host header (points to localhost, not the seller)
519
+ delete headers['host']
520
+
521
+ const serializedReq: SerializedHttpRequest = {
522
+ requestId: randomUUID(),
523
+ method,
524
+ path,
525
+ headers,
526
+ body: new Uint8Array(body),
527
+ }
528
+
529
+ // Discover peers
530
+ const peers = await this._getPeers()
531
+ if (peers.length === 0) {
532
+ log('No sellers available')
533
+ res.writeHead(502, { 'content-type': 'text/plain' })
534
+ res.end('No sellers available on the network. Is a seeder running?')
535
+ return
536
+ }
537
+
538
+ // Use router to select peer
539
+ const router = this._node.router
540
+ const selectedPeer = router
541
+ ? router.selectPeer(serializedReq, peers)
542
+ : peers[0] ?? null
543
+
544
+ if (!selectedPeer) {
545
+ const diagnostics = this._formatPeerSelectionDiagnostics(peers)
546
+ log('Router could not select a peer.', diagnostics)
547
+ res.writeHead(502, { 'content-type': 'text/plain' })
548
+ res.end(`Router could not select a suitable peer. ${diagnostics}`)
549
+ return
550
+ }
551
+
552
+ log(`Routing to peer ${selectedPeer.peerId.slice(0, 12)}...`)
553
+
554
+ // Forward through P2P
555
+ const startTime = Date.now()
556
+ try {
557
+ const response = await this._node.sendRequest(selectedPeer, serializedReq)
558
+ const latencyMs = Date.now() - startTime
559
+
560
+ log(`Response: ${response.statusCode} (${latencyMs}ms, ${response.body.length} bytes)`)
561
+
562
+ const telemetry = computeResponseTelemetry(serializedReq, response.headers, response.body, selectedPeer)
563
+ const responseHeaders = attachAntseedTelemetryHeaders(
564
+ response.headers,
565
+ selectedPeer,
566
+ telemetry,
567
+ serializedReq.requestId,
568
+ latencyMs,
569
+ )
570
+
571
+ // Report result to router for learning
572
+ if (router) {
573
+ router.onResult(selectedPeer, {
574
+ success: response.statusCode < 500,
575
+ latencyMs,
576
+ tokens: telemetry.usage.totalTokens,
577
+ })
578
+ }
579
+
580
+ // Forward response headers and body to the HTTP client
581
+ res.writeHead(response.statusCode, responseHeaders)
582
+ res.end(Buffer.from(response.body))
583
+ } catch (err) {
584
+ const latencyMs = Date.now() - startTime
585
+ const message = err instanceof Error ? err.message : String(err)
586
+ log(`Request failed after ${latencyMs}ms: ${message}`)
587
+
588
+ if (router) {
589
+ router.onResult(selectedPeer, {
590
+ success: false,
591
+ latencyMs,
592
+ tokens: 0,
593
+ })
594
+ }
595
+
596
+ // Invalidate peer cache on connection errors so next request re-discovers
597
+ this._cachedPeers = []
598
+ this._cacheTimestamp = 0
599
+
600
+ res.writeHead(502, { 'content-type': 'text/plain' })
601
+ res.end(`P2P request failed: ${message}`)
602
+ }
603
+ }
604
+ }
@@ -0,0 +1,105 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { AntseedConfig } from '../config/types.js';
5
+
6
+ export interface NodeStatus {
7
+ state: 'seeding' | 'connected' | 'idle';
8
+ peerCount: number;
9
+ earningsToday: string;
10
+ tokensToday: number;
11
+ activeSessions: number;
12
+ uptime: string;
13
+ walletAddress: string | null;
14
+ proxyPort: number | null;
15
+ capacityUsedPercent: number;
16
+ daemonPid: number | null;
17
+ daemonAlive: boolean;
18
+ }
19
+
20
+ /** Stale threshold: 30 seconds */
21
+ const STALE_THRESHOLD_MS = 30_000;
22
+
23
+ /**
24
+ * Check if a process with the given PID is alive.
25
+ * Uses `process.kill(pid, 0)` which checks existence without sending a signal.
26
+ */
27
+ function isProcessAlive(pid: number): boolean {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Query the current node status.
38
+ * Reads from the running daemon's state file at ~/.antseed/daemon.state.json.
39
+ * Uses PID-based liveness check first, falls back to 30s stale threshold.
40
+ * Returns idle state with zeroed metrics if no daemon is running or the state file is stale.
41
+ */
42
+ export async function getNodeStatus(config: AntseedConfig): Promise<NodeStatus> {
43
+ const stateFilePath = join(homedir(), '.antseed', 'daemon.state.json');
44
+
45
+ try {
46
+ const raw = await readFile(stateFilePath, 'utf-8');
47
+ const state = JSON.parse(raw) as Record<string, unknown>;
48
+
49
+ // PID-based liveness check
50
+ const pid = typeof state.pid === 'number' ? state.pid : null;
51
+ const alive = pid !== null && isProcessAlive(pid);
52
+
53
+ // If PID is present and process is dead, return idle immediately
54
+ if (pid !== null && !alive) {
55
+ return idleStatus(config, pid);
56
+ }
57
+
58
+ // Fallback: if no PID in state file, use stale threshold
59
+ if (pid === null) {
60
+ const fileStat = await stat(stateFilePath);
61
+ const ageMs = Date.now() - fileStat.mtimeMs;
62
+ if (ageMs > STALE_THRESHOLD_MS) {
63
+ return idleStatus(config, null);
64
+ }
65
+ }
66
+
67
+ const validStates = ['seeding', 'connected', 'idle'] as const;
68
+ const rawState = typeof state.state === 'string' && validStates.includes(state.state as NodeStatus['state'])
69
+ ? (state.state as NodeStatus['state'])
70
+ : 'idle';
71
+
72
+ return {
73
+ state: rawState,
74
+ peerCount: typeof state.peerCount === 'number' ? state.peerCount : 0,
75
+ earningsToday: typeof state.earningsToday === 'string' ? state.earningsToday : '0',
76
+ tokensToday: typeof state.tokensToday === 'number' ? state.tokensToday : 0,
77
+ activeSessions: typeof state.activeSessions === 'number' ? state.activeSessions : 0,
78
+ uptime: typeof state.uptime === 'string' ? state.uptime : '0s',
79
+ walletAddress: typeof state.walletAddress === 'string' ? state.walletAddress : (config.identity.walletAddress ?? null),
80
+ proxyPort: typeof state.proxyPort === 'number' ? state.proxyPort : null,
81
+ capacityUsedPercent: typeof state.capacityUsedPercent === 'number' ? state.capacityUsedPercent : 0,
82
+ daemonPid: pid,
83
+ daemonAlive: alive,
84
+ };
85
+ } catch {
86
+ // State file doesn't exist or is unreadable — daemon is not running
87
+ return idleStatus(config, null);
88
+ }
89
+ }
90
+
91
+ function idleStatus(config: AntseedConfig, pid: number | null): NodeStatus {
92
+ return {
93
+ state: 'idle',
94
+ peerCount: 0,
95
+ earningsToday: '0',
96
+ tokensToday: 0,
97
+ activeSessions: 0,
98
+ uptime: '0s',
99
+ walletAddress: config.identity.walletAddress ?? null,
100
+ proxyPort: null,
101
+ capacityUsedPercent: 0,
102
+ daemonPid: pid,
103
+ daemonAlive: false,
104
+ };
105
+ }