@tangle-network/tcloud 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -10
- package/dist/chunk-HL4CXKET.js +976 -0
- package/dist/{chunk-G5LUZZKT.js → chunk-STNZT6YR.js} +4 -2
- package/dist/chunk-VD4RNZOC.js +263 -0
- package/dist/cli.cjs +671 -98
- package/dist/cli.js +3 -2
- package/dist/client-CcuHG7_w.d.cts +728 -0
- package/dist/client-CcuHG7_w.d.ts +728 -0
- package/dist/index.cjs +673 -98
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +8 -4
- package/dist/instance.cjs +1235 -0
- package/dist/instance.d.cts +110 -0
- package/dist/instance.d.ts +110 -0
- package/dist/instance.js +229 -0
- package/dist/shielded.cjs +671 -98
- package/dist/shielded.d.cts +61 -2
- package/dist/shielded.d.ts +61 -2
- package/dist/shielded.js +2 -1
- package/package.json +14 -2
- package/dist/chunk-YKLHAS4E.js +0 -661
- package/dist/shielded-xDNVqK4a.d.cts +0 -404
- package/dist/shielded-xDNVqK4a.d.ts +0 -404
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/instance.ts
|
|
31
|
+
var instance_exports = {};
|
|
32
|
+
__export(instance_exports, {
|
|
33
|
+
Instance: () => Instance,
|
|
34
|
+
appendLogLine: () => appendLogLine,
|
|
35
|
+
writeTempHarnessConfig: () => writeTempHarnessConfig
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(instance_exports);
|
|
38
|
+
var import_node_child_process = require("child_process");
|
|
39
|
+
var import_node_fs = require("fs");
|
|
40
|
+
var import_node_path = require("path");
|
|
41
|
+
var import_node_os = require("os");
|
|
42
|
+
var import_node_crypto = require("crypto");
|
|
43
|
+
|
|
44
|
+
// src/private-router.ts
|
|
45
|
+
function secureRandom() {
|
|
46
|
+
const arr = new Uint32Array(1);
|
|
47
|
+
crypto.getRandomValues(arr);
|
|
48
|
+
return arr[0] / (4294967295 + 1);
|
|
49
|
+
}
|
|
50
|
+
var PrivateRouter = class {
|
|
51
|
+
config;
|
|
52
|
+
operators = [];
|
|
53
|
+
usage = /* @__PURE__ */ new Map();
|
|
54
|
+
currentIndex = 0;
|
|
55
|
+
totalRequests = 0;
|
|
56
|
+
constructor(config = {}) {
|
|
57
|
+
this.config = {
|
|
58
|
+
strategy: config.strategy || "round-robin",
|
|
59
|
+
maxRequestsPerOperator: config.maxRequestsPerOperator || 5,
|
|
60
|
+
minOperators: config.minOperators || 3,
|
|
61
|
+
preferRegions: config.preferRegions,
|
|
62
|
+
excludeOperators: config.excludeOperators,
|
|
63
|
+
summarizeOnSwitch: config.summarizeOnSwitch ?? false
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Set the available operator pool */
|
|
67
|
+
setOperators(operators) {
|
|
68
|
+
let filtered = operators.filter(
|
|
69
|
+
(o) => !this.config.excludeOperators?.includes(o.slug)
|
|
70
|
+
);
|
|
71
|
+
if (this.config.preferRegions?.length) {
|
|
72
|
+
filtered.sort((a, b) => {
|
|
73
|
+
const aPreferred = this.config.preferRegions.includes(a.region) ? 0 : 1;
|
|
74
|
+
const bPreferred = this.config.preferRegions.includes(b.region) ? 0 : 1;
|
|
75
|
+
return aPreferred - bPreferred;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
this.operators = filtered;
|
|
79
|
+
}
|
|
80
|
+
/** Select the next operator for a request */
|
|
81
|
+
selectOperator(model) {
|
|
82
|
+
const eligible = this.operators.filter((o) => o.models.includes(model));
|
|
83
|
+
if (eligible.length === 0) return null;
|
|
84
|
+
if (eligible.length < this.config.minOperators) {
|
|
85
|
+
console.warn(
|
|
86
|
+
`[PrivateRouter] Only ${eligible.length} eligible operator(s) for model "${model}", but minOperators requires ${this.config.minOperators}. Refusing to route.`
|
|
87
|
+
);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
this.totalRequests++;
|
|
91
|
+
switch (this.config.strategy) {
|
|
92
|
+
case "round-robin":
|
|
93
|
+
return this.roundRobin(eligible);
|
|
94
|
+
case "random":
|
|
95
|
+
return this.random(eligible);
|
|
96
|
+
case "geo-distributed":
|
|
97
|
+
return this.geoDistributed(eligible);
|
|
98
|
+
case "min-exposure":
|
|
99
|
+
return this.minExposure(eligible);
|
|
100
|
+
case "latency-aware":
|
|
101
|
+
return this.latencyAware(eligible);
|
|
102
|
+
default:
|
|
103
|
+
return this.roundRobin(eligible);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Should we summarize context before this request? (operator is changing) */
|
|
107
|
+
shouldSummarize(model) {
|
|
108
|
+
if (!this.config.summarizeOnSwitch) return false;
|
|
109
|
+
const next = this.peekNextOperator(model);
|
|
110
|
+
const last = this.getLastUsedOperator();
|
|
111
|
+
return next !== null && last !== null && next.slug !== last.slug;
|
|
112
|
+
}
|
|
113
|
+
/** Get privacy stats */
|
|
114
|
+
getStats() {
|
|
115
|
+
return {
|
|
116
|
+
totalRequests: this.totalRequests,
|
|
117
|
+
operatorsUsed: this.usage.size,
|
|
118
|
+
operatorBreakdown: Array.from(this.usage.values()).map((u) => ({
|
|
119
|
+
slug: u.slug,
|
|
120
|
+
requests: u.requestCount,
|
|
121
|
+
lastUsed: u.lastUsedAt
|
|
122
|
+
})),
|
|
123
|
+
strategy: this.config.strategy
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ─── Strategies ────────────────────────────────────────────
|
|
127
|
+
roundRobin(eligible) {
|
|
128
|
+
const op = eligible[this.currentIndex % eligible.length];
|
|
129
|
+
this.currentIndex++;
|
|
130
|
+
this.recordUsage(op);
|
|
131
|
+
return op;
|
|
132
|
+
}
|
|
133
|
+
random(eligible) {
|
|
134
|
+
const idx = Math.floor(secureRandom() * eligible.length);
|
|
135
|
+
const op = eligible[idx];
|
|
136
|
+
this.recordUsage(op);
|
|
137
|
+
return op;
|
|
138
|
+
}
|
|
139
|
+
geoDistributed(eligible) {
|
|
140
|
+
const regionUsage = /* @__PURE__ */ new Map();
|
|
141
|
+
for (const op2 of eligible) {
|
|
142
|
+
const usage = this.usage.get(op2.slug)?.requestCount || 0;
|
|
143
|
+
const current = regionUsage.get(op2.region) || 0;
|
|
144
|
+
regionUsage.set(op2.region, current + usage);
|
|
145
|
+
}
|
|
146
|
+
const sortedRegions = [...regionUsage.entries()].sort((a, b) => a[1] - b[1]);
|
|
147
|
+
const targetRegion = sortedRegions[0]?.[0];
|
|
148
|
+
const regionOps = eligible.filter((o) => o.region === targetRegion);
|
|
149
|
+
const op = regionOps[Math.floor(secureRandom() * regionOps.length)] || eligible[0];
|
|
150
|
+
this.recordUsage(op);
|
|
151
|
+
return op;
|
|
152
|
+
}
|
|
153
|
+
minExposure(eligible) {
|
|
154
|
+
const lastUsed = this.getLastUsedOperator();
|
|
155
|
+
if (lastUsed) {
|
|
156
|
+
const lastUsage = this.usage.get(lastUsed.slug);
|
|
157
|
+
const others = eligible.filter((o) => o.slug !== lastUsed.slug);
|
|
158
|
+
if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) {
|
|
159
|
+
const sorted2 = others.sort(
|
|
160
|
+
(a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
|
|
161
|
+
);
|
|
162
|
+
const op2 = sorted2[0];
|
|
163
|
+
this.recordUsage(op2);
|
|
164
|
+
return op2;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sorted = [...eligible].sort(
|
|
168
|
+
(a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
|
|
169
|
+
);
|
|
170
|
+
const op = sorted[0];
|
|
171
|
+
this.recordUsage(op);
|
|
172
|
+
return op;
|
|
173
|
+
}
|
|
174
|
+
latencyAware(eligible) {
|
|
175
|
+
const weights = eligible.map((o) => {
|
|
176
|
+
const latencyWeight = 1 / Math.max(o.avgLatencyMs, 10);
|
|
177
|
+
const usagePenalty = (this.usage.get(o.slug)?.requestCount || 0) * 0.1;
|
|
178
|
+
return Math.max(latencyWeight - usagePenalty, 0.01);
|
|
179
|
+
});
|
|
180
|
+
const totalWeight = weights.reduce((s, w) => s + w, 0);
|
|
181
|
+
let r = secureRandom() * totalWeight;
|
|
182
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
183
|
+
r -= weights[i];
|
|
184
|
+
if (r <= 0) {
|
|
185
|
+
this.recordUsage(eligible[i]);
|
|
186
|
+
return eligible[i];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const op = eligible[eligible.length - 1];
|
|
190
|
+
this.recordUsage(op);
|
|
191
|
+
return op;
|
|
192
|
+
}
|
|
193
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
194
|
+
recordUsage(op) {
|
|
195
|
+
const existing = this.usage.get(op.slug);
|
|
196
|
+
this.usage.set(op.slug, {
|
|
197
|
+
slug: op.slug,
|
|
198
|
+
requestCount: (existing?.requestCount || 0) + 1,
|
|
199
|
+
lastUsedAt: Date.now()
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
getLastUsedOperator() {
|
|
203
|
+
let latest = null;
|
|
204
|
+
for (const u of this.usage.values()) {
|
|
205
|
+
if (!latest || u.lastUsedAt > latest.lastUsedAt) latest = u;
|
|
206
|
+
}
|
|
207
|
+
if (!latest) return null;
|
|
208
|
+
return this.operators.find((o) => o.slug === latest.slug) || null;
|
|
209
|
+
}
|
|
210
|
+
peekNextOperator(model) {
|
|
211
|
+
const eligible = this.operators.filter((o) => o.models.includes(model));
|
|
212
|
+
if (eligible.length === 0) return null;
|
|
213
|
+
if (eligible.length < this.config.minOperators) return null;
|
|
214
|
+
const last = this.getLastUsedOperator();
|
|
215
|
+
switch (this.config.strategy) {
|
|
216
|
+
case "round-robin":
|
|
217
|
+
return eligible[this.currentIndex % eligible.length];
|
|
218
|
+
case "min-exposure": {
|
|
219
|
+
if (last) {
|
|
220
|
+
const lastUsage = this.usage.get(last.slug);
|
|
221
|
+
const others = eligible.filter((o) => o.slug !== last.slug);
|
|
222
|
+
if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) {
|
|
223
|
+
const sorted2 = others.sort(
|
|
224
|
+
(a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
|
|
225
|
+
);
|
|
226
|
+
return sorted2[0];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const sorted = [...eligible].sort(
|
|
230
|
+
(a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
|
|
231
|
+
);
|
|
232
|
+
return sorted[0];
|
|
233
|
+
}
|
|
234
|
+
case "geo-distributed": {
|
|
235
|
+
const regionUsage = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const op of eligible) {
|
|
237
|
+
const usage = this.usage.get(op.slug)?.requestCount || 0;
|
|
238
|
+
const current = regionUsage.get(op.region) || 0;
|
|
239
|
+
regionUsage.set(op.region, current + usage);
|
|
240
|
+
}
|
|
241
|
+
const sortedRegions = [...regionUsage.entries()].sort((a, b) => a[1] - b[1]);
|
|
242
|
+
const targetRegion = sortedRegions[0]?.[0];
|
|
243
|
+
const regionOps = eligible.filter((o) => o.region === targetRegion);
|
|
244
|
+
return regionOps[0] || eligible[0];
|
|
245
|
+
}
|
|
246
|
+
case "random":
|
|
247
|
+
case "latency-aware":
|
|
248
|
+
default:
|
|
249
|
+
if (last && eligible.length > 1) {
|
|
250
|
+
return eligible.find((o) => o.slug !== last.slug) || eligible[0];
|
|
251
|
+
}
|
|
252
|
+
return eligible[0];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/client.ts
|
|
258
|
+
var DEFAULT_BASE_URL = "https://router.tangle.tools/v1";
|
|
259
|
+
async function proxiedFetch(privacy, url, init, streaming) {
|
|
260
|
+
if (!privacy || privacy.mode === "direct") {
|
|
261
|
+
return fetch(url, init);
|
|
262
|
+
}
|
|
263
|
+
if (privacy.mode === "relayer") {
|
|
264
|
+
if (!privacy.relayerUrl) {
|
|
265
|
+
throw new Error('relayerUrl is required when privacy mode is "relayer"');
|
|
266
|
+
}
|
|
267
|
+
const proxyPath = streaming ? "/relay/proxy-stream" : "/relay/proxy";
|
|
268
|
+
const hdrs = {};
|
|
269
|
+
if (init.headers) {
|
|
270
|
+
const entries = init.headers instanceof Headers ? Array.from(init.headers.entries()) : Object.entries(init.headers);
|
|
271
|
+
for (const [k, v] of entries) hdrs[k] = v;
|
|
272
|
+
}
|
|
273
|
+
return fetch(`${privacy.relayerUrl}${proxyPath}`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
body: JSON.stringify({
|
|
277
|
+
target: url,
|
|
278
|
+
body: typeof init.body === "string" ? JSON.parse(init.body) : init.body,
|
|
279
|
+
headers: hdrs
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (privacy.mode === "socks5") {
|
|
284
|
+
if (!privacy.socksProxy) {
|
|
285
|
+
throw new Error('socksProxy is required when privacy mode is "socks5"');
|
|
286
|
+
}
|
|
287
|
+
const { SocksProxyAgent } = await import("socks-proxy-agent");
|
|
288
|
+
const agent = new SocksProxyAgent(privacy.socksProxy);
|
|
289
|
+
return fetch(url, {
|
|
290
|
+
...init,
|
|
291
|
+
// @ts-expect-error agent is supported by Node's undici but not in the standard RequestInit type
|
|
292
|
+
agent
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return fetch(url, init);
|
|
296
|
+
}
|
|
297
|
+
var DEFAULT_RETRY = {
|
|
298
|
+
maxRetries: 3,
|
|
299
|
+
initialBackoffMs: 500,
|
|
300
|
+
maxBackoffMs: 3e4,
|
|
301
|
+
multiplier: 2,
|
|
302
|
+
retryableStatuses: [429, 500, 502, 503, 504]
|
|
303
|
+
};
|
|
304
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
305
|
+
var TCloudClient = class _TCloudClient {
|
|
306
|
+
baseURL;
|
|
307
|
+
apiKey;
|
|
308
|
+
model;
|
|
309
|
+
headers;
|
|
310
|
+
spendAuthFn;
|
|
311
|
+
privacy;
|
|
312
|
+
limits;
|
|
313
|
+
retryConfig;
|
|
314
|
+
timeoutMs;
|
|
315
|
+
_totalSpent = 0;
|
|
316
|
+
_requestCount = 0;
|
|
317
|
+
privateRouter;
|
|
318
|
+
_cachedOperators = [];
|
|
319
|
+
_operatorsCachedAt = 0;
|
|
320
|
+
static OPERATORS_TTL_MS = 5 * 60 * 1e3;
|
|
321
|
+
constructor(config = {}) {
|
|
322
|
+
this.baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
323
|
+
this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY;
|
|
324
|
+
this.model = config.model || "gpt-4o-mini";
|
|
325
|
+
this.privacy = config.privacy;
|
|
326
|
+
this.limits = config.limits;
|
|
327
|
+
this.retryConfig = config.retry === false ? null : { ...DEFAULT_RETRY, ...config.retry };
|
|
328
|
+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
329
|
+
this.headers = {
|
|
330
|
+
"Content-Type": "application/json",
|
|
331
|
+
"X-Tangle-Client": "tcloud-sdk/0.2.0"
|
|
332
|
+
};
|
|
333
|
+
if (this.apiKey) {
|
|
334
|
+
this.headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
335
|
+
}
|
|
336
|
+
if (config.routing?.mode) {
|
|
337
|
+
this.headers["X-Tangle-Routing"] = config.routing.mode;
|
|
338
|
+
}
|
|
339
|
+
if (config.routing?.prefer) {
|
|
340
|
+
this.headers["X-Tangle-Operator"] = config.routing.prefer;
|
|
341
|
+
}
|
|
342
|
+
if (config.routing?.blueprintId) {
|
|
343
|
+
this.headers["X-Tangle-Blueprint"] = config.routing.blueprintId;
|
|
344
|
+
}
|
|
345
|
+
if (config.routing?.serviceId) {
|
|
346
|
+
this.headers["X-Tangle-Service"] = config.routing.serviceId;
|
|
347
|
+
}
|
|
348
|
+
if (config.routing?.region) {
|
|
349
|
+
this.headers["X-Tangle-Region"] = config.routing.region;
|
|
350
|
+
}
|
|
351
|
+
if (config.routing?.strategy) {
|
|
352
|
+
const strategyMap = {
|
|
353
|
+
"round-robin": "round-robin",
|
|
354
|
+
"lowest-latency": "latency-aware",
|
|
355
|
+
"lowest-price": "round-robin",
|
|
356
|
+
"highest-reputation": "round-robin"
|
|
357
|
+
};
|
|
358
|
+
this.privateRouter = new PrivateRouter({
|
|
359
|
+
strategy: strategyMap[config.routing.strategy] || "round-robin"
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Set the SpendAuth signer for private mode */
|
|
364
|
+
setSpendAuthSigner(fn) {
|
|
365
|
+
this.spendAuthFn = fn;
|
|
366
|
+
}
|
|
367
|
+
/** Current metering stats */
|
|
368
|
+
get usage() {
|
|
369
|
+
return {
|
|
370
|
+
totalSpent: this._totalSpent,
|
|
371
|
+
requestCount: this._requestCount,
|
|
372
|
+
limits: this.limits ? { ...this.limits } : void 0
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** Check spending limits before a request. Throws TCloudError if blocked. */
|
|
376
|
+
checkLimits() {
|
|
377
|
+
if (!this.limits) return;
|
|
378
|
+
if (this.limits.maxRequests && this._requestCount >= this.limits.maxRequests) {
|
|
379
|
+
this.limits.onLimitReached?.({ type: "requests", current: this._requestCount, limit: this.limits.maxRequests });
|
|
380
|
+
throw new TCloudError(429, `Request limit reached (${this._requestCount}/${this.limits.maxRequests})`);
|
|
381
|
+
}
|
|
382
|
+
if (this.limits.maxTotalSpend && this._totalSpent >= this.limits.maxTotalSpend) {
|
|
383
|
+
this.limits.onLimitReached?.({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
|
|
384
|
+
throw new TCloudError(429, `Spending limit reached ($${this._totalSpent.toFixed(6)}/$${this.limits.maxTotalSpend})`);
|
|
385
|
+
}
|
|
386
|
+
if (this.limits.maxRequests && this.limits.onLimitWarning) {
|
|
387
|
+
const pct = this._requestCount / this.limits.maxRequests;
|
|
388
|
+
if (pct >= 0.8) this.limits.onLimitWarning({ type: "requests", current: this._requestCount, limit: this.limits.maxRequests });
|
|
389
|
+
}
|
|
390
|
+
if (this.limits.maxTotalSpend && this.limits.onLimitWarning) {
|
|
391
|
+
const pct = this._totalSpent / this.limits.maxTotalSpend;
|
|
392
|
+
if (pct >= 0.8) this.limits.onLimitWarning({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Ensure the private router has operators loaded (with TTL-based caching) */
|
|
396
|
+
async ensureRouterOperators() {
|
|
397
|
+
if (!this.privateRouter) return;
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
if (this._cachedOperators.length > 0 && now - this._operatorsCachedAt < _TCloudClient.OPERATORS_TTL_MS) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const data = await this.operators();
|
|
403
|
+
this._cachedOperators = (data.operators || []).map((op) => ({
|
|
404
|
+
slug: op.slug,
|
|
405
|
+
endpointUrl: op.endpointUrl,
|
|
406
|
+
region: "",
|
|
407
|
+
reputationScore: op.reputationScore,
|
|
408
|
+
avgLatencyMs: op.avgLatencyMs,
|
|
409
|
+
models: op.models.map((m) => m.modelId)
|
|
410
|
+
}));
|
|
411
|
+
this._operatorsCachedAt = now;
|
|
412
|
+
this.privateRouter.setOperators(this._cachedOperators);
|
|
413
|
+
}
|
|
414
|
+
/** Track cost after a response, using actual pricing from response headers when available */
|
|
415
|
+
trackCost(completion, res) {
|
|
416
|
+
this._requestCount++;
|
|
417
|
+
if (completion.usage) {
|
|
418
|
+
let estimatedCost;
|
|
419
|
+
const inputPrice = res ? parseFloat(res.headers.get("x-tangle-price-input") || "0") : 0;
|
|
420
|
+
const outputPrice = res ? parseFloat(res.headers.get("x-tangle-price-output") || "0") : 0;
|
|
421
|
+
if (inputPrice > 0 || outputPrice > 0) {
|
|
422
|
+
estimatedCost = (completion.usage.prompt_tokens || 0) * inputPrice + (completion.usage.completion_tokens || 0) * outputPrice;
|
|
423
|
+
} else {
|
|
424
|
+
const tokens = completion.usage.total_tokens || 0;
|
|
425
|
+
estimatedCost = tokens * 1e-6;
|
|
426
|
+
}
|
|
427
|
+
this._totalSpent += estimatedCost;
|
|
428
|
+
if (this.limits?.maxCostPerRequest && estimatedCost > this.limits.maxCostPerRequest) {
|
|
429
|
+
this.limits.onLimitReached?.({ type: "cost", current: estimatedCost, limit: this.limits.maxCostPerRequest });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Core fetch with retry + timeout. All helpers build on this.
|
|
435
|
+
* Retries on retryable status codes with exponential backoff + jitter.
|
|
436
|
+
*/
|
|
437
|
+
async _doFetch(url, init, streaming) {
|
|
438
|
+
const retry = this.retryConfig;
|
|
439
|
+
const maxAttempts = retry ? retry.maxRetries + 1 : 1;
|
|
440
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
441
|
+
const controller = new AbortController();
|
|
442
|
+
let timer;
|
|
443
|
+
if (this.timeoutMs > 0 && !streaming) {
|
|
444
|
+
timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const res = await proxiedFetch(this.privacy, url, {
|
|
448
|
+
...init,
|
|
449
|
+
signal: controller.signal
|
|
450
|
+
}, streaming);
|
|
451
|
+
if (res.ok) return res;
|
|
452
|
+
if (retry && attempt < retry.maxRetries && retry.retryableStatuses.includes(res.status)) {
|
|
453
|
+
const backoff = Math.min(
|
|
454
|
+
retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
|
|
455
|
+
retry.maxBackoffMs
|
|
456
|
+
);
|
|
457
|
+
const jitter = backoff * 0.5 * Math.random();
|
|
458
|
+
await new Promise((r) => setTimeout(r, backoff + jitter));
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
462
|
+
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
if (e instanceof TCloudError) throw e;
|
|
465
|
+
if (retry && attempt < retry.maxRetries) {
|
|
466
|
+
const backoff = Math.min(
|
|
467
|
+
retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
|
|
468
|
+
retry.maxBackoffMs
|
|
469
|
+
);
|
|
470
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (e?.name === "AbortError") {
|
|
474
|
+
throw new TCloudError(408, `Request timed out after ${this.timeoutMs}ms`);
|
|
475
|
+
}
|
|
476
|
+
throw new TCloudError(0, e?.message || "Network error");
|
|
477
|
+
} finally {
|
|
478
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
throw new TCloudError(0, "Retry loop exhausted");
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Shared request helper for billable JSON API calls.
|
|
485
|
+
* Enforces: checkLimits → fetch with retry/timeout → error parsing → requestCount.
|
|
486
|
+
*/
|
|
487
|
+
async _request(url, init = {}) {
|
|
488
|
+
this.checkLimits();
|
|
489
|
+
const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
|
|
490
|
+
this._requestCount++;
|
|
491
|
+
return res.json();
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Shared request helper for read-only/non-billable JSON API calls.
|
|
495
|
+
* No limits check, no request counting.
|
|
496
|
+
*/
|
|
497
|
+
async _fetch(url, init = {}) {
|
|
498
|
+
const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
|
|
499
|
+
return res.json();
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Shared request helper for billable calls that return non-JSON (e.g. ArrayBuffer).
|
|
503
|
+
*/
|
|
504
|
+
async _requestRaw(url, init = {}) {
|
|
505
|
+
this.checkLimits();
|
|
506
|
+
const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
|
|
507
|
+
this._requestCount++;
|
|
508
|
+
return res;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Prepare headers for chat requests — operator routing + SpendAuth.
|
|
512
|
+
* Shared between chat() and chatStream() to eliminate duplication.
|
|
513
|
+
*/
|
|
514
|
+
async _prepareChatRequest(model) {
|
|
515
|
+
const headers = { ...this.headers };
|
|
516
|
+
if (this.spendAuthFn) {
|
|
517
|
+
const auth = await this.spendAuthFn();
|
|
518
|
+
headers["X-Payment-Signature"] = JSON.stringify(auth);
|
|
519
|
+
delete headers["Authorization"];
|
|
520
|
+
}
|
|
521
|
+
let baseURL = this.baseURL;
|
|
522
|
+
if (this.privateRouter) {
|
|
523
|
+
await this.ensureRouterOperators();
|
|
524
|
+
const operator = this.privateRouter.selectOperator(model);
|
|
525
|
+
if (operator) {
|
|
526
|
+
baseURL = operator.endpointUrl.replace(/\/$/, "");
|
|
527
|
+
headers["X-Tangle-Operator"] = operator.slug;
|
|
528
|
+
delete headers["Authorization"];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return { headers, baseURL };
|
|
532
|
+
}
|
|
533
|
+
/** Build the chat completions request body */
|
|
534
|
+
_chatBody(options, stream) {
|
|
535
|
+
return JSON.stringify({
|
|
536
|
+
model: options.model || this.model,
|
|
537
|
+
messages: options.messages,
|
|
538
|
+
temperature: options.temperature,
|
|
539
|
+
max_tokens: options.maxTokens,
|
|
540
|
+
stream,
|
|
541
|
+
stop: options.stop,
|
|
542
|
+
top_p: options.topP,
|
|
543
|
+
frequency_penalty: options.frequencyPenalty,
|
|
544
|
+
presence_penalty: options.presencePenalty,
|
|
545
|
+
response_format: options.responseFormat,
|
|
546
|
+
tools: options.tools,
|
|
547
|
+
tool_choice: options.toolChoice,
|
|
548
|
+
...options.providerOptions
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/** Chat completion (non-streaming) */
|
|
552
|
+
async chat(options) {
|
|
553
|
+
this.checkLimits();
|
|
554
|
+
const { headers, baseURL } = await this._prepareChatRequest(options.model || this.model);
|
|
555
|
+
const res = await this._doFetch(`${baseURL}/chat/completions`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers,
|
|
558
|
+
body: this._chatBody(options, false)
|
|
559
|
+
}, false);
|
|
560
|
+
const completion = await res.json();
|
|
561
|
+
this.trackCost(completion, res);
|
|
562
|
+
return completion;
|
|
563
|
+
}
|
|
564
|
+
/** Chat completion (streaming) — returns an async iterator of chunks */
|
|
565
|
+
async *chatStream(options) {
|
|
566
|
+
this.checkLimits();
|
|
567
|
+
this._requestCount++;
|
|
568
|
+
const { headers, baseURL } = await this._prepareChatRequest(options.model || this.model);
|
|
569
|
+
const res = await this._doFetch(`${baseURL}/chat/completions`, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers,
|
|
572
|
+
body: this._chatBody(options, true)
|
|
573
|
+
}, true);
|
|
574
|
+
const reader = res.body.getReader();
|
|
575
|
+
const decoder = new TextDecoder();
|
|
576
|
+
let buf = "";
|
|
577
|
+
while (true) {
|
|
578
|
+
const { done, value } = await reader.read();
|
|
579
|
+
if (done) break;
|
|
580
|
+
buf += decoder.decode(value, { stream: true });
|
|
581
|
+
if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
|
|
582
|
+
const lines = buf.split("\n");
|
|
583
|
+
buf = lines.pop() || "";
|
|
584
|
+
for (const line of lines) {
|
|
585
|
+
if (!line.startsWith("data: ")) continue;
|
|
586
|
+
const data = line.slice(6).trim();
|
|
587
|
+
if (data === "[DONE]") {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
yield JSON.parse(data);
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Convenience: send a single message and get the text response */
|
|
598
|
+
async ask(message, modelOrOptions) {
|
|
599
|
+
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
|
|
600
|
+
const completion = await this.chat({
|
|
601
|
+
messages: [{ role: "user", content: message }],
|
|
602
|
+
...options
|
|
603
|
+
});
|
|
604
|
+
return completion.choices[0]?.message?.content || "";
|
|
605
|
+
}
|
|
606
|
+
/** Convenience: send a single message and get the full completion (with usage) */
|
|
607
|
+
async askFull(message, modelOrOptions) {
|
|
608
|
+
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
|
|
609
|
+
return this.chat({
|
|
610
|
+
messages: [{ role: "user", content: message }],
|
|
611
|
+
...options
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/** Convenience: stream a single message and yield text chunks */
|
|
615
|
+
async *askStream(message, modelOrOptions) {
|
|
616
|
+
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
|
|
617
|
+
for await (const chunk of this.chatStream({
|
|
618
|
+
messages: [{ role: "user", content: message }],
|
|
619
|
+
...options
|
|
620
|
+
})) {
|
|
621
|
+
const content = chunk.choices[0]?.delta?.content;
|
|
622
|
+
if (content) yield content;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** List available models */
|
|
626
|
+
async models() {
|
|
627
|
+
const data = await this._fetch(`${this.baseURL}/models`);
|
|
628
|
+
return data.data || [];
|
|
629
|
+
}
|
|
630
|
+
/** List active operators */
|
|
631
|
+
async operators() {
|
|
632
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
633
|
+
return this._fetch(`${apiRoot}/api/operators`);
|
|
634
|
+
}
|
|
635
|
+
/** Get credit balance */
|
|
636
|
+
async credits() {
|
|
637
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
638
|
+
return this._fetch(`${apiRoot}/api/billing`);
|
|
639
|
+
}
|
|
640
|
+
/** Add credits */
|
|
641
|
+
async addCredits(amount) {
|
|
642
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
643
|
+
return this._fetch(`${apiRoot}/api/billing`, {
|
|
644
|
+
method: "POST",
|
|
645
|
+
body: JSON.stringify({ amount })
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** Create a new API key */
|
|
649
|
+
async createKey(name) {
|
|
650
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
651
|
+
return this._fetch(`${apiRoot}/api/keys`, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
body: JSON.stringify({ name })
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/** List API keys */
|
|
657
|
+
async keys() {
|
|
658
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
659
|
+
return this._fetch(`${apiRoot}/api/keys`);
|
|
660
|
+
}
|
|
661
|
+
/** Revoke an API key */
|
|
662
|
+
async revokeKey(id) {
|
|
663
|
+
const apiRoot = this.baseURL.replace(/\/v1$/, "");
|
|
664
|
+
const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys/${id}`, {
|
|
665
|
+
method: "DELETE",
|
|
666
|
+
headers: this.headers
|
|
667
|
+
}, false);
|
|
668
|
+
if (!res.ok) {
|
|
669
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
670
|
+
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** Generate embeddings */
|
|
674
|
+
async embeddings(options) {
|
|
675
|
+
return this._request(`${this.baseURL}/embeddings`, {
|
|
676
|
+
method: "POST",
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
model: options.model || "text-embedding-3-small",
|
|
679
|
+
input: options.input
|
|
680
|
+
})
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/** Generate images */
|
|
684
|
+
async imageGenerate(options) {
|
|
685
|
+
return this._request(`${this.baseURL}/images/generations`, {
|
|
686
|
+
method: "POST",
|
|
687
|
+
body: JSON.stringify({
|
|
688
|
+
model: options.model || "dall-e-3",
|
|
689
|
+
prompt: options.prompt,
|
|
690
|
+
n: options.n,
|
|
691
|
+
size: options.size,
|
|
692
|
+
quality: options.quality,
|
|
693
|
+
response_format: options.response_format
|
|
694
|
+
})
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/** Rerank documents by relevance to a query */
|
|
698
|
+
async rerank(options) {
|
|
699
|
+
return this._request(`${this.baseURL}/rerank`, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
model: options.model || "rerank-english-v3.0",
|
|
703
|
+
query: options.query,
|
|
704
|
+
documents: options.documents,
|
|
705
|
+
top_n: options.top_n
|
|
706
|
+
})
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/** Text-to-speech */
|
|
710
|
+
async speech(options) {
|
|
711
|
+
const res = await this._requestRaw(`${this.baseURL}/audio/speech`, {
|
|
712
|
+
method: "POST",
|
|
713
|
+
body: JSON.stringify({
|
|
714
|
+
model: options.model || "tts-1",
|
|
715
|
+
input: options.input,
|
|
716
|
+
voice: options.voice || "alloy"
|
|
717
|
+
})
|
|
718
|
+
});
|
|
719
|
+
return res.arrayBuffer();
|
|
720
|
+
}
|
|
721
|
+
/** Legacy completions endpoint */
|
|
722
|
+
async completions(options) {
|
|
723
|
+
return this._request(`${this.baseURL}/completions`, {
|
|
724
|
+
method: "POST",
|
|
725
|
+
body: JSON.stringify({
|
|
726
|
+
model: options.model || this.model,
|
|
727
|
+
prompt: options.prompt,
|
|
728
|
+
temperature: options.temperature,
|
|
729
|
+
max_tokens: options.maxTokens,
|
|
730
|
+
stop: options.stop,
|
|
731
|
+
top_p: options.topP
|
|
732
|
+
})
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/** Audio transcription (speech-to-text) */
|
|
736
|
+
async transcribe(file, options) {
|
|
737
|
+
const formData = new FormData();
|
|
738
|
+
formData.append("file", file, "audio.webm");
|
|
739
|
+
formData.append("model", options?.model || "whisper-1");
|
|
740
|
+
if (options?.language) formData.append("language", options.language);
|
|
741
|
+
if (options?.prompt) formData.append("prompt", options.prompt);
|
|
742
|
+
const headers = { ...this.headers };
|
|
743
|
+
delete headers["Content-Type"];
|
|
744
|
+
this.checkLimits();
|
|
745
|
+
const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/transcriptions`, {
|
|
746
|
+
method: "POST",
|
|
747
|
+
headers,
|
|
748
|
+
body: formData
|
|
749
|
+
}, false);
|
|
750
|
+
if (!res.ok) {
|
|
751
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
752
|
+
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
|
|
753
|
+
}
|
|
754
|
+
this._requestCount++;
|
|
755
|
+
return res.json();
|
|
756
|
+
}
|
|
757
|
+
/** Create a fine-tuning job */
|
|
758
|
+
async fineTuneCreate(options) {
|
|
759
|
+
return this._request(`${this.baseURL}/fine_tuning/jobs`, {
|
|
760
|
+
method: "POST",
|
|
761
|
+
body: JSON.stringify(options)
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/** List fine-tuning jobs */
|
|
765
|
+
async fineTuneList() {
|
|
766
|
+
return this._fetch(`${this.baseURL}/fine_tuning/jobs`);
|
|
767
|
+
}
|
|
768
|
+
/** Submit a batch of chat requests */
|
|
769
|
+
async batch(requests) {
|
|
770
|
+
return this._request(`${this.baseURL}/batch`, {
|
|
771
|
+
method: "POST",
|
|
772
|
+
body: JSON.stringify({ requests })
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/** Get batch job status */
|
|
776
|
+
async batchStatus(jobId) {
|
|
777
|
+
return this._fetch(`${this.baseURL}/batch?id=${jobId}`);
|
|
778
|
+
}
|
|
779
|
+
/** Generate video */
|
|
780
|
+
async videoGenerate(options) {
|
|
781
|
+
return this._request(`${this.baseURL}/video/generate`, {
|
|
782
|
+
method: "POST",
|
|
783
|
+
body: JSON.stringify(options)
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
/** Get video generation status */
|
|
787
|
+
async videoStatus(id) {
|
|
788
|
+
return this._fetch(`${this.baseURL}/video?id=${id}`);
|
|
789
|
+
}
|
|
790
|
+
/** Generate an avatar video (lip-synced talking head from audio + face image).
|
|
791
|
+
* Returns 202 with a job_id for async polling via avatarJobStatus(). */
|
|
792
|
+
async avatarGenerate(options) {
|
|
793
|
+
return this._request(`${this.baseURL}/avatar/generate`, {
|
|
794
|
+
method: "POST",
|
|
795
|
+
body: JSON.stringify(options)
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
/** Poll an avatar generation job by ID. */
|
|
799
|
+
async avatarJobStatus(jobId) {
|
|
800
|
+
return this._fetch(`${this.baseURL}/avatar/jobs/${jobId}`);
|
|
801
|
+
}
|
|
802
|
+
/** Poll an avatar job until it reaches a terminal state (completed/failed).
|
|
803
|
+
* Returns the final job status. Throws on failure. */
|
|
804
|
+
async pollAvatarJob(jobId, options) {
|
|
805
|
+
const interval = options?.intervalMs ?? 5e3;
|
|
806
|
+
const timeout = options?.timeoutMs ?? 3e5;
|
|
807
|
+
const deadline = Date.now() + timeout;
|
|
808
|
+
while (Date.now() < deadline) {
|
|
809
|
+
const job = await this.avatarJobStatus(jobId);
|
|
810
|
+
if (job.status === "completed") return job;
|
|
811
|
+
if (job.status === "failed") {
|
|
812
|
+
throw new TCloudError(500, job.error || `Avatar job ${jobId} failed`);
|
|
813
|
+
}
|
|
814
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
815
|
+
}
|
|
816
|
+
throw new TCloudError(408, `Avatar job ${jobId} timed out after ${timeout}ms`);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Watch an async job via SSE until it reaches a terminal state.
|
|
820
|
+
* Works with avatar, video, and training blueprint operators.
|
|
821
|
+
*
|
|
822
|
+
* @param jobId - The job ID returned by the creation endpoint
|
|
823
|
+
* @param options - Optional: operatorUrl override, onEvent callback
|
|
824
|
+
* @returns The final JobEvent (completed/failed/cancelled)
|
|
825
|
+
*/
|
|
826
|
+
async watchJob(jobId, options) {
|
|
827
|
+
const base = options?.operatorUrl?.replace(/\/$/, "") || this.baseURL;
|
|
828
|
+
const url = `${base}/v1/jobs/${encodeURIComponent(jobId)}/events`;
|
|
829
|
+
const timeout = options?.timeout ?? 3e5;
|
|
830
|
+
const controller = new AbortController();
|
|
831
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
832
|
+
try {
|
|
833
|
+
const watchHeaders = {
|
|
834
|
+
...this.headers,
|
|
835
|
+
Accept: "text/event-stream"
|
|
836
|
+
};
|
|
837
|
+
if (options?.operatorUrl) {
|
|
838
|
+
delete watchHeaders["Authorization"];
|
|
839
|
+
}
|
|
840
|
+
if (options?.sseToken) {
|
|
841
|
+
watchHeaders["Authorization"] = `Bearer ${options.sseToken}`;
|
|
842
|
+
}
|
|
843
|
+
const res = await proxiedFetch(this.privacy, url, {
|
|
844
|
+
headers: watchHeaders,
|
|
845
|
+
signal: controller.signal
|
|
846
|
+
}, true);
|
|
847
|
+
if (!res.ok) {
|
|
848
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
849
|
+
throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
|
|
850
|
+
}
|
|
851
|
+
const reader = res.body.getReader();
|
|
852
|
+
const decoder = new TextDecoder();
|
|
853
|
+
let buf = "";
|
|
854
|
+
const terminalStatuses = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
|
|
855
|
+
while (true) {
|
|
856
|
+
const { done, value } = await reader.read();
|
|
857
|
+
if (done) {
|
|
858
|
+
throw new TCloudError(502, `SSE stream ended without terminal event for job ${jobId}`);
|
|
859
|
+
}
|
|
860
|
+
buf += decoder.decode(value, { stream: true });
|
|
861
|
+
if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
|
|
862
|
+
const lines = buf.split("\n");
|
|
863
|
+
buf = lines.pop() || "";
|
|
864
|
+
for (const line of lines) {
|
|
865
|
+
if (!line.startsWith("data: ")) continue;
|
|
866
|
+
const data = line.slice(6).trim();
|
|
867
|
+
if (!data || data === "[DONE]") continue;
|
|
868
|
+
let event;
|
|
869
|
+
try {
|
|
870
|
+
event = JSON.parse(data);
|
|
871
|
+
} catch {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
options?.onEvent?.(event);
|
|
876
|
+
} catch (cbErr) {
|
|
877
|
+
console.error("watchJob onEvent callback error:", cbErr);
|
|
878
|
+
}
|
|
879
|
+
if (terminalStatuses.has(event.status)) {
|
|
880
|
+
return event;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
} catch (err) {
|
|
885
|
+
if (err?.name === "AbortError") {
|
|
886
|
+
throw new TCloudError(408, `Job ${jobId} timed out after ${timeout}ms`);
|
|
887
|
+
}
|
|
888
|
+
throw err;
|
|
889
|
+
} finally {
|
|
890
|
+
clearTimeout(timer);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
// Vector Store (requires operator routing — X-Tangle-Service/Blueprint/Operator)
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
/** Create a vector collection on the operator's vector store */
|
|
897
|
+
async createCollection(options) {
|
|
898
|
+
return this._request(`${this.baseURL}/collections`, {
|
|
899
|
+
method: "POST",
|
|
900
|
+
body: JSON.stringify(options)
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/** List collections on the operator's vector store */
|
|
904
|
+
async listCollections() {
|
|
905
|
+
return this._fetch(`${this.baseURL}/collections`);
|
|
906
|
+
}
|
|
907
|
+
/** Upsert vectors into a collection */
|
|
908
|
+
async upsertVectors(collection, vectors) {
|
|
909
|
+
return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/upsert`, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
body: JSON.stringify({ vectors })
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
/** Similarity search in a collection */
|
|
915
|
+
async queryVectors(collection, options) {
|
|
916
|
+
return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/query`, {
|
|
917
|
+
method: "POST",
|
|
918
|
+
body: JSON.stringify(options)
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
/** RAG query — embed text + search collection in one call */
|
|
922
|
+
async ragQuery(options) {
|
|
923
|
+
return this._request(`${this.baseURL}/rag`, {
|
|
924
|
+
method: "POST",
|
|
925
|
+
body: JSON.stringify(options)
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
/** Search models by name, provider, or capability */
|
|
929
|
+
async searchModels(query) {
|
|
930
|
+
const all = await this.models();
|
|
931
|
+
const q = query.toLowerCase();
|
|
932
|
+
return all.filter(
|
|
933
|
+
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m._provider && m._provider.toLowerCase().includes(q)
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
/** Estimate cost for a request (without sending it) */
|
|
937
|
+
async estimateCost(options) {
|
|
938
|
+
const models = await this.models();
|
|
939
|
+
const model = models.find((m) => m.id === (options.model || this.model));
|
|
940
|
+
if (!model) return { inputCost: 0, outputCost: 0, total: 0 };
|
|
941
|
+
const inputCost = options.inputTokens * parseFloat(model.pricing.prompt);
|
|
942
|
+
const outputCost = options.outputTokens * parseFloat(model.pricing.completion);
|
|
943
|
+
return { inputCost, outputCost, total: inputCost + outputCost };
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Get a pricing spectrum across resource tiers for a model.
|
|
947
|
+
*
|
|
948
|
+
* Uses REAL per-operator pricing from `operator.models[].inputPrice`.
|
|
949
|
+
* Each tier filters operators by GPU count and TEE capability, then
|
|
950
|
+
* reports the cheapest and most expensive operator for that config.
|
|
951
|
+
*
|
|
952
|
+
* @param options.model - Model ID to price (falls back to client default)
|
|
953
|
+
* @param options.tiers - Number of tiers (1-7, default 5)
|
|
954
|
+
*/
|
|
955
|
+
async pricingSpectrum(options) {
|
|
956
|
+
const requestedTiers = Math.max(1, Math.min(options.tiers ?? 5, ALL_TIERS.length));
|
|
957
|
+
const modelId = options.model || this.model;
|
|
958
|
+
const selected = selectTiers(ALL_TIERS, requestedTiers);
|
|
959
|
+
const operatorData = await this.operators();
|
|
960
|
+
const allOperators = operatorData.operators || [];
|
|
961
|
+
return selected.map((tier) => {
|
|
962
|
+
const matching = allOperators.filter((op) => {
|
|
963
|
+
if (tier.gpu > 0 && (op.gpuCount ?? 0) < tier.gpu) return false;
|
|
964
|
+
if (tier.tee && !op.teeAttested) return false;
|
|
965
|
+
return true;
|
|
966
|
+
});
|
|
967
|
+
const prices = matching.map((op) => op.models.find((m) => m.modelId === modelId)?.inputPrice).filter((p) => p != null && p > 0).sort((a, b) => a - b);
|
|
968
|
+
const cheapestPrice = prices[0];
|
|
969
|
+
const priciestPrice = prices.length > 1 ? prices[prices.length - 1] : void 0;
|
|
970
|
+
return {
|
|
971
|
+
tier: tier.name,
|
|
972
|
+
config: tier,
|
|
973
|
+
cheapestPrice,
|
|
974
|
+
priciestPrice: priciestPrice !== cheapestPrice ? priciestPrice : void 0,
|
|
975
|
+
cheapest: cheapestPrice != null ? formatPrice(cheapestPrice) : "no operators for this config",
|
|
976
|
+
priciest: priciestPrice != null && priciestPrice !== cheapestPrice ? formatPrice(priciestPrice) : void 0,
|
|
977
|
+
availableOperators: matching.length,
|
|
978
|
+
operatorsWithModel: prices.length
|
|
979
|
+
};
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
var ALL_TIERS = [
|
|
984
|
+
{ name: "cpu-only", cpu: 4, ramGb: 16, gpu: 0, tee: false },
|
|
985
|
+
{ name: "gpu", cpu: 8, ramGb: 32, gpu: 1, tee: false },
|
|
986
|
+
{ name: "gpu-tee", cpu: 8, ramGb: 32, gpu: 1, tee: true },
|
|
987
|
+
{ name: "multi-gpu", cpu: 32, ramGb: 128, gpu: 2, tee: false },
|
|
988
|
+
{ name: "multi-gpu-tee", cpu: 32, ramGb: 128, gpu: 2, tee: true },
|
|
989
|
+
{ name: "max-gpu", cpu: 64, ramGb: 256, gpu: 4, tee: false },
|
|
990
|
+
{ name: "max-gpu-tee", cpu: 64, ramGb: 256, gpu: 4, tee: true }
|
|
991
|
+
];
|
|
992
|
+
function selectTiers(all, n) {
|
|
993
|
+
if (n >= all.length) return [...all];
|
|
994
|
+
if (n <= 1) return [all[0]];
|
|
995
|
+
if (n === 2) return [all[0], all[all.length - 1]];
|
|
996
|
+
const result = [all[0]];
|
|
997
|
+
const step = (all.length - 1) / (n - 1);
|
|
998
|
+
for (let i = 1; i < n - 1; i++) {
|
|
999
|
+
result.push(all[Math.round(i * step)]);
|
|
1000
|
+
}
|
|
1001
|
+
result.push(all[all.length - 1]);
|
|
1002
|
+
return result;
|
|
1003
|
+
}
|
|
1004
|
+
function formatPrice(pricePerToken) {
|
|
1005
|
+
return `$${(pricePerToken * 1e3).toFixed(6)}/1K tokens`;
|
|
1006
|
+
}
|
|
1007
|
+
var TCloudError = class extends Error {
|
|
1008
|
+
constructor(status, message) {
|
|
1009
|
+
super(message);
|
|
1010
|
+
this.status = status;
|
|
1011
|
+
this.name = "TCloudError";
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// src/instance.ts
|
|
1016
|
+
var MAX_LOG_LINES = 1e4;
|
|
1017
|
+
function appendLogLine(buffer, line, maxLines = MAX_LOG_LINES) {
|
|
1018
|
+
buffer.push(line);
|
|
1019
|
+
if (buffer.length > maxLines) {
|
|
1020
|
+
buffer.shift();
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
var Instance = class _Instance {
|
|
1024
|
+
child;
|
|
1025
|
+
_config;
|
|
1026
|
+
_stopped = false;
|
|
1027
|
+
logBuffer;
|
|
1028
|
+
maxLogLines = MAX_LOG_LINES;
|
|
1029
|
+
constructor(child, config, logBuffer) {
|
|
1030
|
+
this.child = child;
|
|
1031
|
+
this._config = config;
|
|
1032
|
+
this.logBuffer = logBuffer;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Start a new harness instance. Resolves once `cargo tangle harness up`
|
|
1036
|
+
* prints its "Harness up" marker, indicating all blueprints are healthy.
|
|
1037
|
+
*
|
|
1038
|
+
* Throws on timeout, process exit before ready, or missing `cargo-tangle`.
|
|
1039
|
+
*/
|
|
1040
|
+
static async start(options = {}) {
|
|
1041
|
+
const cargoBinary = options.cargoBinary ?? "cargo-tangle";
|
|
1042
|
+
const timeoutMs = options.timeoutMs ?? 3e5;
|
|
1043
|
+
const routerUrl = options.routerUrl ?? "http://localhost:3000";
|
|
1044
|
+
const args = ["tangle", "harness", "up"];
|
|
1045
|
+
if (options.config) {
|
|
1046
|
+
args.push("--config", options.config);
|
|
1047
|
+
}
|
|
1048
|
+
if (options.only && options.only.length > 0) {
|
|
1049
|
+
args.push("--only", options.only.join(","));
|
|
1050
|
+
}
|
|
1051
|
+
if (options.includeAnvilLogs) {
|
|
1052
|
+
args.push("--include-anvil-logs");
|
|
1053
|
+
}
|
|
1054
|
+
const child = (0, import_node_child_process.spawn)(cargoBinary, args, {
|
|
1055
|
+
cwd: options.cwd ?? process.cwd(),
|
|
1056
|
+
env: process.env,
|
|
1057
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1058
|
+
});
|
|
1059
|
+
const logBuffer = [];
|
|
1060
|
+
const pushLog = (line) => {
|
|
1061
|
+
appendLogLine(logBuffer, line, MAX_LOG_LINES);
|
|
1062
|
+
if (!options.quiet) {
|
|
1063
|
+
process.stdout.write(line + "\n");
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
let stdoutTail = "";
|
|
1067
|
+
let stderrTail = "";
|
|
1068
|
+
child.stdout?.on("data", (chunk) => {
|
|
1069
|
+
stdoutTail += chunk.toString();
|
|
1070
|
+
const lines = stdoutTail.split("\n");
|
|
1071
|
+
stdoutTail = lines.pop() ?? "";
|
|
1072
|
+
for (const line of lines) pushLog(line);
|
|
1073
|
+
});
|
|
1074
|
+
child.stderr?.on("data", (chunk) => {
|
|
1075
|
+
stderrTail += chunk.toString();
|
|
1076
|
+
const lines = stderrTail.split("\n");
|
|
1077
|
+
stderrTail = lines.pop() ?? "";
|
|
1078
|
+
for (const line of lines) pushLog(line);
|
|
1079
|
+
});
|
|
1080
|
+
const blueprintNames = [];
|
|
1081
|
+
try {
|
|
1082
|
+
await new Promise((resolve, reject) => {
|
|
1083
|
+
let settled = false;
|
|
1084
|
+
let timer;
|
|
1085
|
+
let pollInterval;
|
|
1086
|
+
const cleanup = () => {
|
|
1087
|
+
if (settled) return;
|
|
1088
|
+
settled = true;
|
|
1089
|
+
if (pollInterval !== void 0) clearInterval(pollInterval);
|
|
1090
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1091
|
+
child.removeListener("exit", onExit);
|
|
1092
|
+
child.removeListener("error", onError);
|
|
1093
|
+
};
|
|
1094
|
+
const onExit = (code) => {
|
|
1095
|
+
if (settled) return;
|
|
1096
|
+
cleanup();
|
|
1097
|
+
reject(
|
|
1098
|
+
new Error(
|
|
1099
|
+
`cargo tangle harness exited with code ${code} before becoming ready. Last 20 lines:
|
|
1100
|
+
${logBuffer.slice(-20).join("\n")}`
|
|
1101
|
+
)
|
|
1102
|
+
);
|
|
1103
|
+
};
|
|
1104
|
+
const onError = (err) => {
|
|
1105
|
+
if (settled) return;
|
|
1106
|
+
cleanup();
|
|
1107
|
+
reject(new Error(`Failed to spawn ${cargoBinary}: ${err.message}`));
|
|
1108
|
+
};
|
|
1109
|
+
timer = setTimeout(() => {
|
|
1110
|
+
if (settled) return;
|
|
1111
|
+
cleanup();
|
|
1112
|
+
reject(
|
|
1113
|
+
new Error(
|
|
1114
|
+
`Timed out after ${timeoutMs}ms waiting for harness to start. Last 20 lines:
|
|
1115
|
+
${logBuffer.slice(-20).join("\n")}`
|
|
1116
|
+
)
|
|
1117
|
+
);
|
|
1118
|
+
}, timeoutMs);
|
|
1119
|
+
child.once("exit", onExit);
|
|
1120
|
+
child.once("error", onError);
|
|
1121
|
+
pollInterval = setInterval(() => {
|
|
1122
|
+
for (const line of logBuffer) {
|
|
1123
|
+
const match = line.match(/Harness up\.\s+(\d+)\s+blueprint/i);
|
|
1124
|
+
if (match) {
|
|
1125
|
+
if (settled) return;
|
|
1126
|
+
cleanup();
|
|
1127
|
+
resolve();
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const bpMatch = line.match(/Starting blueprint-manager for '([^']+)'/);
|
|
1131
|
+
if (bpMatch) {
|
|
1132
|
+
blueprintNames.push(bpMatch[1]);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}, 100);
|
|
1136
|
+
});
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
if (!child.killed) {
|
|
1139
|
+
child.kill("SIGTERM");
|
|
1140
|
+
}
|
|
1141
|
+
throw err;
|
|
1142
|
+
}
|
|
1143
|
+
return new _Instance(
|
|
1144
|
+
child,
|
|
1145
|
+
{ routerUrl, blueprints: blueprintNames },
|
|
1146
|
+
logBuffer
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
/** URL of the router serving this instance */
|
|
1150
|
+
get routerUrl() {
|
|
1151
|
+
return this._config.routerUrl;
|
|
1152
|
+
}
|
|
1153
|
+
/** Names of blueprints that were started */
|
|
1154
|
+
get blueprints() {
|
|
1155
|
+
return [...this._config.blueprints];
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Create a pre-configured {@link TCloudClient} pointed at this instance's router.
|
|
1159
|
+
* Merges any passed config with the instance's routerUrl (instance wins).
|
|
1160
|
+
*/
|
|
1161
|
+
client(config = {}) {
|
|
1162
|
+
return new TCloudClient({
|
|
1163
|
+
...config,
|
|
1164
|
+
baseURL: this._config.routerUrl
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
/** Return the last N log lines from the harness */
|
|
1168
|
+
logs(lines = 100) {
|
|
1169
|
+
return this.logBuffer.slice(-lines);
|
|
1170
|
+
}
|
|
1171
|
+
/** Whether the harness process is still running */
|
|
1172
|
+
get isRunning() {
|
|
1173
|
+
return !this._stopped && !this.child.killed && this.child.exitCode === null;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Stop the harness. Sends SIGTERM, waits up to 10s for clean shutdown,
|
|
1177
|
+
* then SIGKILL.
|
|
1178
|
+
*/
|
|
1179
|
+
async stop(timeoutMs = 1e4) {
|
|
1180
|
+
if (this._stopped) return;
|
|
1181
|
+
this._stopped = true;
|
|
1182
|
+
if (this.child.exitCode !== null) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
this.child.kill("SIGTERM");
|
|
1186
|
+
await new Promise((resolve) => {
|
|
1187
|
+
const timer = setTimeout(() => {
|
|
1188
|
+
if (this.child.exitCode === null) {
|
|
1189
|
+
this.child.kill("SIGKILL");
|
|
1190
|
+
}
|
|
1191
|
+
resolve();
|
|
1192
|
+
}, timeoutMs);
|
|
1193
|
+
this.child.once("exit", () => {
|
|
1194
|
+
clearTimeout(timer);
|
|
1195
|
+
resolve();
|
|
1196
|
+
});
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
function writeTempHarnessConfig(blueprints) {
|
|
1201
|
+
const dir = (0, import_node_path.join)(
|
|
1202
|
+
(0, import_node_os.tmpdir)(),
|
|
1203
|
+
`tangle-harness-${process.pid}-${Date.now()}-${(0, import_node_crypto.randomUUID)()}`
|
|
1204
|
+
);
|
|
1205
|
+
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
1206
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
1207
|
+
}
|
|
1208
|
+
const file = (0, import_node_path.join)(dir, "harness.toml");
|
|
1209
|
+
const lines = [];
|
|
1210
|
+
lines.push("[chain]");
|
|
1211
|
+
lines.push("anvil = true");
|
|
1212
|
+
lines.push("");
|
|
1213
|
+
for (const bp of blueprints) {
|
|
1214
|
+
lines.push("[[blueprint]]");
|
|
1215
|
+
lines.push(`name = "${bp.name}"`);
|
|
1216
|
+
lines.push(`path = "${bp.path}"`);
|
|
1217
|
+
if (bp.port !== void 0) {
|
|
1218
|
+
lines.push(`port = ${bp.port}`);
|
|
1219
|
+
}
|
|
1220
|
+
if (bp.env) {
|
|
1221
|
+
for (const [k, v] of Object.entries(bp.env)) {
|
|
1222
|
+
lines.push(`env.${k} = "${v.replace(/"/g, '\\"')}"`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
lines.push("");
|
|
1226
|
+
}
|
|
1227
|
+
(0, import_node_fs.writeFileSync)(file, lines.join("\n"));
|
|
1228
|
+
return file;
|
|
1229
|
+
}
|
|
1230
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1231
|
+
0 && (module.exports = {
|
|
1232
|
+
Instance,
|
|
1233
|
+
appendLogLine,
|
|
1234
|
+
writeTempHarnessConfig
|
|
1235
|
+
});
|