@tangle-network/tcloud 0.1.4 → 0.3.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.
@@ -0,0 +1,1434 @@
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 DEFAULT_PLATFORM_URL = "https://id.tangle.tools";
306
+ var TCloudClient = class _TCloudClient {
307
+ baseURL;
308
+ platformURL;
309
+ apiKey;
310
+ model;
311
+ headers;
312
+ spendAuthFn;
313
+ privacy;
314
+ limits;
315
+ retryConfig;
316
+ timeoutMs;
317
+ _totalSpent = 0;
318
+ _requestCount = 0;
319
+ privateRouter;
320
+ _cachedOperators = [];
321
+ _operatorsCachedAt = 0;
322
+ static OPERATORS_TTL_MS = 5 * 60 * 1e3;
323
+ constructor(config = {}) {
324
+ this.baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/$/, "");
325
+ this.platformURL = (config.platformURL || DEFAULT_PLATFORM_URL).replace(/\/$/, "");
326
+ this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY;
327
+ this.model = config.model || "gpt-4o-mini";
328
+ this.privacy = config.privacy;
329
+ this.limits = config.limits;
330
+ this.retryConfig = config.retry === false ? null : { ...DEFAULT_RETRY, ...config.retry };
331
+ this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
332
+ this.headers = {
333
+ "Content-Type": "application/json",
334
+ "X-Tangle-Client": "tcloud-sdk/0.2.0"
335
+ };
336
+ if (this.apiKey) {
337
+ this.headers["Authorization"] = `Bearer ${this.apiKey}`;
338
+ }
339
+ if (config.routing?.mode) {
340
+ this.headers["X-Tangle-Routing"] = config.routing.mode;
341
+ }
342
+ if (config.routing?.prefer) {
343
+ this.headers["X-Tangle-Operator"] = config.routing.prefer;
344
+ }
345
+ if (config.routing?.blueprintId) {
346
+ this.headers["X-Tangle-Blueprint"] = config.routing.blueprintId;
347
+ }
348
+ if (config.routing?.serviceId) {
349
+ this.headers["X-Tangle-Service"] = config.routing.serviceId;
350
+ }
351
+ if (config.routing?.region) {
352
+ this.headers["X-Tangle-Region"] = config.routing.region;
353
+ }
354
+ if (config.routing?.strategy) {
355
+ const strategyMap = {
356
+ "round-robin": "round-robin",
357
+ "lowest-latency": "latency-aware",
358
+ "lowest-price": "round-robin",
359
+ "highest-reputation": "round-robin"
360
+ };
361
+ this.privateRouter = new PrivateRouter({
362
+ strategy: strategyMap[config.routing.strategy] || "round-robin"
363
+ });
364
+ }
365
+ }
366
+ /** Set the SpendAuth signer for private mode */
367
+ setSpendAuthSigner(fn) {
368
+ this.spendAuthFn = fn;
369
+ }
370
+ /** Current metering stats */
371
+ get usage() {
372
+ return {
373
+ totalSpent: this._totalSpent,
374
+ requestCount: this._requestCount,
375
+ limits: this.limits ? { ...this.limits } : void 0
376
+ };
377
+ }
378
+ /** Check spending limits before a request. Throws TCloudError if blocked. */
379
+ checkLimits() {
380
+ if (!this.limits) return;
381
+ if (this.limits.maxRequests && this._requestCount >= this.limits.maxRequests) {
382
+ this.limits.onLimitReached?.({ type: "requests", current: this._requestCount, limit: this.limits.maxRequests });
383
+ throw new TCloudError(429, `Request limit reached (${this._requestCount}/${this.limits.maxRequests})`);
384
+ }
385
+ if (this.limits.maxTotalSpend && this._totalSpent >= this.limits.maxTotalSpend) {
386
+ this.limits.onLimitReached?.({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
387
+ throw new TCloudError(429, `Spending limit reached ($${this._totalSpent.toFixed(6)}/$${this.limits.maxTotalSpend})`);
388
+ }
389
+ if (this.limits.maxRequests && this.limits.onLimitWarning) {
390
+ const pct = this._requestCount / this.limits.maxRequests;
391
+ if (pct >= 0.8) this.limits.onLimitWarning({ type: "requests", current: this._requestCount, limit: this.limits.maxRequests });
392
+ }
393
+ if (this.limits.maxTotalSpend && this.limits.onLimitWarning) {
394
+ const pct = this._totalSpent / this.limits.maxTotalSpend;
395
+ if (pct >= 0.8) this.limits.onLimitWarning({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
396
+ }
397
+ }
398
+ /** Ensure the private router has operators loaded (with TTL-based caching) */
399
+ async ensureRouterOperators() {
400
+ if (!this.privateRouter) return;
401
+ const now = Date.now();
402
+ if (this._cachedOperators.length > 0 && now - this._operatorsCachedAt < _TCloudClient.OPERATORS_TTL_MS) {
403
+ return;
404
+ }
405
+ const data = await this.operators();
406
+ this._cachedOperators = (data.operators || []).map((op) => ({
407
+ slug: op.slug,
408
+ endpointUrl: op.endpointUrl,
409
+ region: "",
410
+ reputationScore: op.reputationScore,
411
+ avgLatencyMs: op.avgLatencyMs,
412
+ models: op.models.map((m) => m.modelId)
413
+ }));
414
+ this._operatorsCachedAt = now;
415
+ this.privateRouter.setOperators(this._cachedOperators);
416
+ }
417
+ /** Track cost after a response, using actual pricing from response headers when available */
418
+ trackCost(completion, res) {
419
+ this._requestCount++;
420
+ if (completion.usage) {
421
+ let estimatedCost;
422
+ const inputPrice = res ? parseFloat(res.headers.get("x-tangle-price-input") || "0") : 0;
423
+ const outputPrice = res ? parseFloat(res.headers.get("x-tangle-price-output") || "0") : 0;
424
+ if (inputPrice > 0 || outputPrice > 0) {
425
+ estimatedCost = (completion.usage.prompt_tokens || 0) * inputPrice + (completion.usage.completion_tokens || 0) * outputPrice;
426
+ } else {
427
+ const tokens = completion.usage.total_tokens || 0;
428
+ estimatedCost = tokens * 1e-6;
429
+ }
430
+ this._totalSpent += estimatedCost;
431
+ if (this.limits?.maxCostPerRequest && estimatedCost > this.limits.maxCostPerRequest) {
432
+ this.limits.onLimitReached?.({ type: "cost", current: estimatedCost, limit: this.limits.maxCostPerRequest });
433
+ }
434
+ }
435
+ }
436
+ /**
437
+ * Core fetch with retry + timeout. All helpers build on this.
438
+ * Retries on retryable status codes with exponential backoff + jitter.
439
+ */
440
+ async _doFetch(url, init, streaming) {
441
+ const retry = this.retryConfig;
442
+ const maxAttempts = retry ? retry.maxRetries + 1 : 1;
443
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
444
+ const controller = new AbortController();
445
+ let timer;
446
+ if (this.timeoutMs > 0 && !streaming) {
447
+ timer = setTimeout(() => controller.abort(), this.timeoutMs);
448
+ }
449
+ try {
450
+ const res = await proxiedFetch(this.privacy, url, {
451
+ ...init,
452
+ signal: controller.signal
453
+ }, streaming);
454
+ if (res.ok) return res;
455
+ if (retry && attempt < retry.maxRetries && retry.retryableStatuses.includes(res.status)) {
456
+ const backoff = Math.min(
457
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
458
+ retry.maxBackoffMs
459
+ );
460
+ const jitter = backoff * 0.5 * Math.random();
461
+ await new Promise((r) => setTimeout(r, backoff + jitter));
462
+ continue;
463
+ }
464
+ const err = await res.json().catch(() => ({ error: res.statusText }));
465
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
466
+ } catch (e) {
467
+ if (e instanceof TCloudError) throw e;
468
+ if (retry && attempt < retry.maxRetries) {
469
+ const backoff = Math.min(
470
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
471
+ retry.maxBackoffMs
472
+ );
473
+ await new Promise((r) => setTimeout(r, backoff));
474
+ continue;
475
+ }
476
+ if (e?.name === "AbortError") {
477
+ throw new TCloudError(408, `Request timed out after ${this.timeoutMs}ms`);
478
+ }
479
+ throw new TCloudError(0, e?.message || "Network error");
480
+ } finally {
481
+ if (timer !== void 0) clearTimeout(timer);
482
+ }
483
+ }
484
+ throw new TCloudError(0, "Retry loop exhausted");
485
+ }
486
+ /**
487
+ * Shared request helper for billable JSON API calls.
488
+ * Enforces: checkLimits → fetch with retry/timeout → error parsing → requestCount.
489
+ */
490
+ async _request(url, init = {}) {
491
+ this.checkLimits();
492
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
493
+ this._requestCount++;
494
+ return res.json();
495
+ }
496
+ /**
497
+ * Shared request helper for read-only/non-billable JSON API calls.
498
+ * No limits check, no request counting.
499
+ */
500
+ async _fetch(url, init = {}) {
501
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
502
+ return res.json();
503
+ }
504
+ /**
505
+ * Shared request helper for billable calls that return non-JSON (e.g. ArrayBuffer).
506
+ */
507
+ async _requestRaw(url, init = {}) {
508
+ this.checkLimits();
509
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
510
+ this._requestCount++;
511
+ return res;
512
+ }
513
+ /**
514
+ * Prepare headers for chat requests — operator routing + SpendAuth +
515
+ * bridge short-circuit headers when `options.bridge` is set.
516
+ * Shared between chat() and chatStream() to eliminate duplication.
517
+ */
518
+ async _prepareChatRequest(model, bridge) {
519
+ const headers = { ...this.headers };
520
+ if (this.spendAuthFn) {
521
+ const auth = await this.spendAuthFn();
522
+ headers["X-Payment-Signature"] = JSON.stringify(auth);
523
+ delete headers["Authorization"];
524
+ }
525
+ let baseURL = this.baseURL;
526
+ if (this.privateRouter) {
527
+ await this.ensureRouterOperators();
528
+ const operator = this.privateRouter.selectOperator(model);
529
+ if (operator) {
530
+ baseURL = operator.endpointUrl.replace(/\/$/, "");
531
+ headers["X-Tangle-Operator"] = operator.slug;
532
+ delete headers["Authorization"];
533
+ }
534
+ }
535
+ if (bridge) {
536
+ headers["X-Bridge-Unlock"] = bridge.unlock;
537
+ if (bridge.resume) headers["X-Resume"] = bridge.resume;
538
+ if (bridge.bridgeUrl) headers["X-Bridge-Url"] = bridge.bridgeUrl;
539
+ if (bridge.bridgeBearer) headers["X-Bridge-Bearer"] = bridge.bridgeBearer;
540
+ }
541
+ return { headers, baseURL };
542
+ }
543
+ /**
544
+ * Resolve the effective model string. When a bridge is set, rewrite to
545
+ * `bridge/<harness>/<model>` (or `bridge/<harness>` if no model).
546
+ */
547
+ _effectiveModel(options) {
548
+ if (options.bridge) {
549
+ return options.bridge.model ? `bridge/${options.bridge.harness}/${options.bridge.model}` : `bridge/${options.bridge.harness}`;
550
+ }
551
+ return options.model || this.model;
552
+ }
553
+ /** Build the chat completions request body */
554
+ _chatBody(options, stream) {
555
+ return JSON.stringify({
556
+ model: this._effectiveModel(options),
557
+ messages: options.messages,
558
+ temperature: options.temperature,
559
+ max_tokens: options.maxTokens,
560
+ stream,
561
+ stop: options.stop,
562
+ top_p: options.topP,
563
+ frequency_penalty: options.frequencyPenalty,
564
+ presence_penalty: options.presencePenalty,
565
+ response_format: options.responseFormat,
566
+ tools: options.tools,
567
+ tool_choice: options.toolChoice,
568
+ ...options.gateway ? { gateway: options.gateway } : {},
569
+ ...options.providerOptions
570
+ });
571
+ }
572
+ /** Chat completion (non-streaming) */
573
+ async chat(options) {
574
+ this.checkLimits();
575
+ const { headers, baseURL } = await this._prepareChatRequest(
576
+ this._effectiveModel(options),
577
+ options.bridge
578
+ );
579
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
580
+ method: "POST",
581
+ headers,
582
+ body: this._chatBody(options, false)
583
+ }, false);
584
+ const completion = await res.json();
585
+ this.trackCost(completion, res);
586
+ return completion;
587
+ }
588
+ /** Chat completion (streaming) — returns an async iterator of chunks */
589
+ async *chatStream(options) {
590
+ this.checkLimits();
591
+ this._requestCount++;
592
+ const { headers, baseURL } = await this._prepareChatRequest(
593
+ this._effectiveModel(options),
594
+ options.bridge
595
+ );
596
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
597
+ method: "POST",
598
+ headers,
599
+ body: this._chatBody(options, true)
600
+ }, true);
601
+ const reader = res.body.getReader();
602
+ const decoder = new TextDecoder();
603
+ let buf = "";
604
+ while (true) {
605
+ const { done, value } = await reader.read();
606
+ if (done) break;
607
+ buf += decoder.decode(value, { stream: true });
608
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
609
+ const lines = buf.split("\n");
610
+ buf = lines.pop() || "";
611
+ for (const line of lines) {
612
+ if (!line.startsWith("data: ")) continue;
613
+ const data = line.slice(6).trim();
614
+ if (data === "[DONE]") {
615
+ return;
616
+ }
617
+ try {
618
+ yield JSON.parse(data);
619
+ } catch {
620
+ }
621
+ }
622
+ }
623
+ }
624
+ /**
625
+ * Bridge — scoped helper for a subscription-backed CLI harness behind
626
+ * the Tangle Router's cli-bridge. Returns a mini-client bound to
627
+ * (harness, unlock, resume) so you don't thread those through every
628
+ * call.
629
+ *
630
+ * ```ts
631
+ * const kimi = tcloud.bridge({ harness: 'kimi', model: 'kimi-for-coding', unlock: UNLOCK, resume: 'pr-42' })
632
+ * await kimi.ask('review this diff…')
633
+ * for await (const chunk of kimi.stream('continue…')) process.stdout.write(chunk)
634
+ * ```
635
+ *
636
+ * Sessions persist across process restarts — use the same `resume` id
637
+ * to land on the same CLI conversation (context intact, no replay tax).
638
+ */
639
+ bridge(cfg) {
640
+ return new BridgeSession(this, cfg);
641
+ }
642
+ /** Convenience: send a single message and get the text response */
643
+ async ask(message, modelOrOptions) {
644
+ const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
645
+ const completion = await this.chat({
646
+ messages: [{ role: "user", content: message }],
647
+ ...options
648
+ });
649
+ return completion.choices[0]?.message?.content || "";
650
+ }
651
+ /** Convenience: send a single message and get the full completion (with usage) */
652
+ async askFull(message, modelOrOptions) {
653
+ const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
654
+ return this.chat({
655
+ messages: [{ role: "user", content: message }],
656
+ ...options
657
+ });
658
+ }
659
+ /** Convenience: stream a single message and yield text chunks */
660
+ async *askStream(message, modelOrOptions) {
661
+ const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
662
+ for await (const chunk of this.chatStream({
663
+ messages: [{ role: "user", content: message }],
664
+ ...options
665
+ })) {
666
+ const content = chunk.choices[0]?.delta?.content;
667
+ if (content) yield content;
668
+ }
669
+ }
670
+ /** List available models */
671
+ async models() {
672
+ const data = await this._fetch(`${this.baseURL}/models`);
673
+ return data.data || [];
674
+ }
675
+ /** List active operators */
676
+ async operators() {
677
+ const apiRoot = this.baseURL.replace(/\/v1$/, "");
678
+ return this._fetch(`${apiRoot}/api/operators`);
679
+ }
680
+ // ── Billing (via id.tangle.tools) ──
681
+ /** Get credit balance */
682
+ async credits() {
683
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/balance`);
684
+ return data;
685
+ }
686
+ /** Add credits via Stripe checkout. Returns the checkout URL. */
687
+ async addCredits(amount) {
688
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/topup`, {
689
+ method: "POST",
690
+ body: JSON.stringify({ amount })
691
+ });
692
+ return data;
693
+ }
694
+ /** Get transaction history */
695
+ async transactions(limit = 50) {
696
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/transactions?limit=${limit}`);
697
+ return data;
698
+ }
699
+ // ── API Keys (via id.tangle.tools) ──
700
+ /**
701
+ * Create a new API key.
702
+ * When called with an API key (not session), the new key is automatically
703
+ * a child of the calling key — enabling hierarchical key delegation.
704
+ *
705
+ * Pass `parentKeyId` explicitly to create a child of a specific key.
706
+ * Child keys inherit the parent's product scope, allowedModels, and rpmLimit
707
+ * if not specified. Budget cannot exceed the parent's remaining budget.
708
+ */
709
+ async createKey(opts) {
710
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys`, {
711
+ method: "POST",
712
+ body: JSON.stringify(opts)
713
+ });
714
+ return data;
715
+ }
716
+ /** Get a single API key by ID */
717
+ async getKey(id) {
718
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`);
719
+ return data;
720
+ }
721
+ /**
722
+ * List API keys.
723
+ * Pass `children: true` to list child keys of the calling API key.
724
+ */
725
+ async keys(opts) {
726
+ const q = opts?.children ? "?children=true" : "";
727
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys${q}`);
728
+ return data;
729
+ }
730
+ /**
731
+ * Update an API key's limits.
732
+ * Can adjust budget, allowedModels, rpmLimit, expiresAt, and name.
733
+ */
734
+ async updateKey(id, updates) {
735
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`, {
736
+ method: "PATCH",
737
+ body: JSON.stringify(updates)
738
+ });
739
+ return data;
740
+ }
741
+ /** Revoke an API key. If the key has children, they are also revoked recursively. */
742
+ async revokeKey(id) {
743
+ await this._fetch(`${this.platformURL}/v1/keys/${id}`, { method: "DELETE" });
744
+ }
745
+ /** Rotate an API key — creates new key with same config, revokes old */
746
+ async rotateKey(id) {
747
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}/rotate`, { method: "POST" });
748
+ return data;
749
+ }
750
+ // ── Projects (via id.tangle.tools) ──
751
+ /** Create a project for usage attribution */
752
+ async createProject(name, product) {
753
+ const { data } = await this._fetch(`${this.platformURL}/v1/projects`, {
754
+ method: "POST",
755
+ body: JSON.stringify({ name, product })
756
+ });
757
+ return data;
758
+ }
759
+ /** List projects */
760
+ async projects() {
761
+ const { data } = await this._fetch(`${this.platformURL}/v1/projects`);
762
+ return data;
763
+ }
764
+ /** Generate embeddings */
765
+ async embeddings(options) {
766
+ return this._request(`${this.baseURL}/embeddings`, {
767
+ method: "POST",
768
+ body: JSON.stringify({
769
+ model: options.model || "text-embedding-3-small",
770
+ input: options.input
771
+ })
772
+ });
773
+ }
774
+ /** Generate images */
775
+ async imageGenerate(options) {
776
+ return this._request(`${this.baseURL}/images/generations`, {
777
+ method: "POST",
778
+ body: JSON.stringify({
779
+ model: options.model || "dall-e-3",
780
+ prompt: options.prompt,
781
+ n: options.n,
782
+ size: options.size,
783
+ quality: options.quality,
784
+ response_format: options.response_format
785
+ })
786
+ });
787
+ }
788
+ /** Rerank documents by relevance to a query */
789
+ async rerank(options) {
790
+ return this._request(`${this.baseURL}/rerank`, {
791
+ method: "POST",
792
+ body: JSON.stringify({
793
+ model: options.model || "rerank-english-v3.0",
794
+ query: options.query,
795
+ documents: options.documents,
796
+ top_n: options.top_n
797
+ })
798
+ });
799
+ }
800
+ /** Text-to-speech */
801
+ async speech(options) {
802
+ const res = await this._requestRaw(`${this.baseURL}/audio/speech`, {
803
+ method: "POST",
804
+ body: JSON.stringify({
805
+ model: options.model || "tts-1",
806
+ input: options.input,
807
+ voice: options.voice || "alloy"
808
+ })
809
+ });
810
+ return res.arrayBuffer();
811
+ }
812
+ /** Legacy completions endpoint */
813
+ async completions(options) {
814
+ return this._request(`${this.baseURL}/completions`, {
815
+ method: "POST",
816
+ body: JSON.stringify({
817
+ model: options.model || this.model,
818
+ prompt: options.prompt,
819
+ temperature: options.temperature,
820
+ max_tokens: options.maxTokens,
821
+ stop: options.stop,
822
+ top_p: options.topP
823
+ })
824
+ });
825
+ }
826
+ /** Audio transcription (speech-to-text) */
827
+ async transcribe(file, options) {
828
+ const formData = new FormData();
829
+ formData.append("file", file, "audio.webm");
830
+ formData.append("model", options?.model || "whisper-1");
831
+ if (options?.language) formData.append("language", options.language);
832
+ if (options?.prompt) formData.append("prompt", options.prompt);
833
+ const headers = { ...this.headers };
834
+ delete headers["Content-Type"];
835
+ this.checkLimits();
836
+ const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/transcriptions`, {
837
+ method: "POST",
838
+ headers,
839
+ body: formData
840
+ }, false);
841
+ if (!res.ok) {
842
+ const err = await res.json().catch(() => ({ error: res.statusText }));
843
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
844
+ }
845
+ this._requestCount++;
846
+ return res.json();
847
+ }
848
+ /** Create a fine-tuning job */
849
+ async fineTuneCreate(options) {
850
+ return this._request(`${this.baseURL}/fine_tuning/jobs`, {
851
+ method: "POST",
852
+ body: JSON.stringify(options)
853
+ });
854
+ }
855
+ /** List fine-tuning jobs */
856
+ async fineTuneList() {
857
+ return this._fetch(`${this.baseURL}/fine_tuning/jobs`);
858
+ }
859
+ /** Submit a batch of chat requests */
860
+ async batch(requests) {
861
+ return this._request(`${this.baseURL}/batch`, {
862
+ method: "POST",
863
+ body: JSON.stringify({ requests })
864
+ });
865
+ }
866
+ /** Get batch job status */
867
+ async batchStatus(jobId) {
868
+ return this._fetch(`${this.baseURL}/batch?id=${jobId}`);
869
+ }
870
+ /** Generate video */
871
+ async videoGenerate(options) {
872
+ return this._request(`${this.baseURL}/video/generate`, {
873
+ method: "POST",
874
+ body: JSON.stringify(options)
875
+ });
876
+ }
877
+ /** Get video generation status */
878
+ async videoStatus(id) {
879
+ return this._fetch(`${this.baseURL}/video?id=${id}`);
880
+ }
881
+ /** Generate an avatar video (lip-synced talking head from audio + face image).
882
+ * Returns 202 with a job_id for async polling via avatarJobStatus(). */
883
+ async avatarGenerate(options) {
884
+ return this._request(`${this.baseURL}/avatar/generate`, {
885
+ method: "POST",
886
+ body: JSON.stringify(options)
887
+ });
888
+ }
889
+ /** Poll an avatar generation job by ID. */
890
+ async avatarJobStatus(jobId) {
891
+ return this._fetch(`${this.baseURL}/avatar/jobs/${jobId}`);
892
+ }
893
+ /** Poll an avatar job until it reaches a terminal state (completed/failed).
894
+ * Returns the final job status. Throws on failure. */
895
+ async pollAvatarJob(jobId, options) {
896
+ const interval = options?.intervalMs ?? 5e3;
897
+ const timeout = options?.timeoutMs ?? 3e5;
898
+ const deadline = Date.now() + timeout;
899
+ while (Date.now() < deadline) {
900
+ const job = await this.avatarJobStatus(jobId);
901
+ if (job.status === "completed") return job;
902
+ if (job.status === "failed") {
903
+ throw new TCloudError(500, job.error || `Avatar job ${jobId} failed`);
904
+ }
905
+ await new Promise((r) => setTimeout(r, interval));
906
+ }
907
+ throw new TCloudError(408, `Avatar job ${jobId} timed out after ${timeout}ms`);
908
+ }
909
+ /**
910
+ * Watch an async job via SSE until it reaches a terminal state.
911
+ * Works with avatar, video, and training blueprint operators.
912
+ *
913
+ * @param jobId - The job ID returned by the creation endpoint
914
+ * @param options - Optional: operatorUrl override, onEvent callback
915
+ * @returns The final JobEvent (completed/failed/cancelled)
916
+ */
917
+ async watchJob(jobId, options) {
918
+ const base = options?.operatorUrl?.replace(/\/$/, "") || this.baseURL;
919
+ const url = `${base}/v1/jobs/${encodeURIComponent(jobId)}/events`;
920
+ const timeout = options?.timeout ?? 3e5;
921
+ const controller = new AbortController();
922
+ const timer = setTimeout(() => controller.abort(), timeout);
923
+ try {
924
+ const watchHeaders = {
925
+ ...this.headers,
926
+ Accept: "text/event-stream"
927
+ };
928
+ if (options?.operatorUrl) {
929
+ delete watchHeaders["Authorization"];
930
+ }
931
+ if (options?.sseToken) {
932
+ watchHeaders["Authorization"] = `Bearer ${options.sseToken}`;
933
+ }
934
+ const res = await proxiedFetch(this.privacy, url, {
935
+ headers: watchHeaders,
936
+ signal: controller.signal
937
+ }, true);
938
+ if (!res.ok) {
939
+ const err = await res.json().catch(() => ({ error: res.statusText }));
940
+ throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
941
+ }
942
+ const reader = res.body.getReader();
943
+ const decoder = new TextDecoder();
944
+ let buf = "";
945
+ const terminalStatuses = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
946
+ while (true) {
947
+ const { done, value } = await reader.read();
948
+ if (done) {
949
+ throw new TCloudError(502, `SSE stream ended without terminal event for job ${jobId}`);
950
+ }
951
+ buf += decoder.decode(value, { stream: true });
952
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
953
+ const lines = buf.split("\n");
954
+ buf = lines.pop() || "";
955
+ for (const line of lines) {
956
+ if (!line.startsWith("data: ")) continue;
957
+ const data = line.slice(6).trim();
958
+ if (!data || data === "[DONE]") continue;
959
+ let event;
960
+ try {
961
+ event = JSON.parse(data);
962
+ } catch {
963
+ continue;
964
+ }
965
+ try {
966
+ options?.onEvent?.(event);
967
+ } catch (cbErr) {
968
+ console.error("watchJob onEvent callback error:", cbErr);
969
+ }
970
+ if (terminalStatuses.has(event.status)) {
971
+ return event;
972
+ }
973
+ }
974
+ }
975
+ } catch (err) {
976
+ if (err?.name === "AbortError") {
977
+ throw new TCloudError(408, `Job ${jobId} timed out after ${timeout}ms`);
978
+ }
979
+ throw err;
980
+ } finally {
981
+ clearTimeout(timer);
982
+ }
983
+ }
984
+ // ---------------------------------------------------------------------------
985
+ // Vector Store (requires operator routing — X-Tangle-Service/Blueprint/Operator)
986
+ // ---------------------------------------------------------------------------
987
+ /** Create a vector collection on the operator's vector store */
988
+ async createCollection(options) {
989
+ return this._request(`${this.baseURL}/collections`, {
990
+ method: "POST",
991
+ body: JSON.stringify(options)
992
+ });
993
+ }
994
+ /** List collections on the operator's vector store */
995
+ async listCollections() {
996
+ return this._fetch(`${this.baseURL}/collections`);
997
+ }
998
+ /** Upsert vectors into a collection */
999
+ async upsertVectors(collection, vectors) {
1000
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/upsert`, {
1001
+ method: "POST",
1002
+ body: JSON.stringify({ vectors })
1003
+ });
1004
+ }
1005
+ /** Similarity search in a collection */
1006
+ async queryVectors(collection, options) {
1007
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/query`, {
1008
+ method: "POST",
1009
+ body: JSON.stringify(options)
1010
+ });
1011
+ }
1012
+ /** RAG query — embed text + search collection in one call */
1013
+ async ragQuery(options) {
1014
+ return this._request(`${this.baseURL}/rag`, {
1015
+ method: "POST",
1016
+ body: JSON.stringify(options)
1017
+ });
1018
+ }
1019
+ /** Search models by name, provider, or capability */
1020
+ async searchModels(query) {
1021
+ const all = await this.models();
1022
+ const q = query.toLowerCase();
1023
+ return all.filter(
1024
+ (m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m._provider && m._provider.toLowerCase().includes(q)
1025
+ );
1026
+ }
1027
+ /** Estimate cost for a request (without sending it) */
1028
+ async estimateCost(options) {
1029
+ const models = await this.models();
1030
+ const model = models.find((m) => m.id === (options.model || this.model));
1031
+ if (!model) return { inputCost: 0, outputCost: 0, total: 0 };
1032
+ const inputCost = options.inputTokens * parseFloat(model.pricing.prompt);
1033
+ const outputCost = options.outputTokens * parseFloat(model.pricing.completion);
1034
+ return { inputCost, outputCost, total: inputCost + outputCost };
1035
+ }
1036
+ /**
1037
+ * Get a pricing spectrum across resource tiers for a model.
1038
+ *
1039
+ * Uses REAL per-operator pricing from `operator.models[].inputPrice`.
1040
+ * Each tier filters operators by GPU count and TEE capability, then
1041
+ * reports the cheapest and most expensive operator for that config.
1042
+ *
1043
+ * @param options.model - Model ID to price (falls back to client default)
1044
+ * @param options.tiers - Number of tiers (1-7, default 5)
1045
+ */
1046
+ async pricingSpectrum(options) {
1047
+ const requestedTiers = Math.max(1, Math.min(options.tiers ?? 5, ALL_TIERS.length));
1048
+ const modelId = options.model || this.model;
1049
+ const selected = selectTiers(ALL_TIERS, requestedTiers);
1050
+ const operatorData = await this.operators();
1051
+ const allOperators = operatorData.operators || [];
1052
+ return selected.map((tier) => {
1053
+ const matching = allOperators.filter((op) => {
1054
+ if (tier.gpu > 0 && (op.gpuCount ?? 0) < tier.gpu) return false;
1055
+ if (tier.tee && !op.teeAttested) return false;
1056
+ return true;
1057
+ });
1058
+ const prices = matching.map((op) => op.models.find((m) => m.modelId === modelId)?.inputPrice).filter((p) => p != null && p > 0).sort((a, b) => a - b);
1059
+ const cheapestPrice = prices[0];
1060
+ const priciestPrice = prices.length > 1 ? prices[prices.length - 1] : void 0;
1061
+ return {
1062
+ tier: tier.name,
1063
+ config: tier,
1064
+ cheapestPrice,
1065
+ priciestPrice: priciestPrice !== cheapestPrice ? priciestPrice : void 0,
1066
+ cheapest: cheapestPrice != null ? formatPrice(cheapestPrice) : "no operators for this config",
1067
+ priciest: priciestPrice != null && priciestPrice !== cheapestPrice ? formatPrice(priciestPrice) : void 0,
1068
+ availableOperators: matching.length,
1069
+ operatorsWithModel: prices.length
1070
+ };
1071
+ });
1072
+ }
1073
+ // ── Eval ──────────────────────────────────────────────────────────────
1074
+ get _apiRoot() {
1075
+ return this.baseURL.replace(/\/v1$/, "");
1076
+ }
1077
+ async eval(opts) {
1078
+ return this._request(`${this._apiRoot}/api/eval`, { method: "POST", body: JSON.stringify(opts) });
1079
+ }
1080
+ async createSuite(opts) {
1081
+ return this._request(`${this._apiRoot}/api/eval/suites`, { method: "POST", body: JSON.stringify(opts) });
1082
+ }
1083
+ async listSuites() {
1084
+ return this._fetch(`${this._apiRoot}/api/eval/suites`);
1085
+ }
1086
+ async runSuite(suiteId, opts) {
1087
+ return this._request(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`, { method: "POST", body: JSON.stringify(opts || {}) });
1088
+ }
1089
+ async listRuns(suiteId) {
1090
+ return this._fetch(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`);
1091
+ }
1092
+ async getRun(runId) {
1093
+ return this._fetch(`${this._apiRoot}/api/eval/runs/${runId}`);
1094
+ }
1095
+ async setBaseline(runId) {
1096
+ await this._request(`${this._apiRoot}/api/eval/runs/${runId}`, { method: "PATCH", body: JSON.stringify({ baseline: true }) });
1097
+ }
1098
+ // ── Sandbox ──────────────────────────────────────────────────────────
1099
+ async sandboxPricing(opts) {
1100
+ const p = new URLSearchParams();
1101
+ if (opts?.cpu) p.set("cpu", String(opts.cpu));
1102
+ if (opts?.ram) p.set("ram", String(opts.ram));
1103
+ if (opts?.disk) p.set("disk", String(opts.disk));
1104
+ return this._fetch(`${this._apiRoot}/api/sandbox/pricing?${p}`);
1105
+ }
1106
+ async sandboxStatus() {
1107
+ return this._fetch(`${this._apiRoot}/api/sandbox/link-key`);
1108
+ }
1109
+ async sandboxProvision() {
1110
+ return this._request(`${this._apiRoot}/api/sandbox/provision`, { method: "POST" });
1111
+ }
1112
+ async sandboxCreate(opts) {
1113
+ return this._request(`${this._apiRoot}/api/sandbox/sessions`, { method: "POST", body: JSON.stringify(opts) });
1114
+ }
1115
+ async sandboxList() {
1116
+ return this._fetch(`${this._apiRoot}/api/sandbox/sessions`);
1117
+ }
1118
+ async sandboxStats(sandboxId) {
1119
+ return this._fetch(`${this._apiRoot}/api/sandbox/stats/${sandboxId}`);
1120
+ }
1121
+ async sandboxDestroy(sessionId) {
1122
+ return this._request(`${this._apiRoot}/api/sandbox/sessions/${sessionId}`, { method: "DELETE" });
1123
+ }
1124
+ // ── User Info ────────────────────────────────────────────────────────
1125
+ async userInfo() {
1126
+ return this._fetch(`${this._apiRoot}/api/auth/userinfo`);
1127
+ }
1128
+ };
1129
+ var ALL_TIERS = [
1130
+ { name: "cpu-only", cpu: 4, ramGb: 16, gpu: 0, tee: false },
1131
+ { name: "gpu", cpu: 8, ramGb: 32, gpu: 1, tee: false },
1132
+ { name: "gpu-tee", cpu: 8, ramGb: 32, gpu: 1, tee: true },
1133
+ { name: "multi-gpu", cpu: 32, ramGb: 128, gpu: 2, tee: false },
1134
+ { name: "multi-gpu-tee", cpu: 32, ramGb: 128, gpu: 2, tee: true },
1135
+ { name: "max-gpu", cpu: 64, ramGb: 256, gpu: 4, tee: false },
1136
+ { name: "max-gpu-tee", cpu: 64, ramGb: 256, gpu: 4, tee: true }
1137
+ ];
1138
+ var BridgeSession = class _BridgeSession {
1139
+ constructor(client, cfg) {
1140
+ this.client = client;
1141
+ this.cfg = cfg;
1142
+ }
1143
+ /** Full chat completion (non-streaming). */
1144
+ async chat(options) {
1145
+ return this.client.chat({ ...options, bridge: this.cfg });
1146
+ }
1147
+ /** Stream OpenAI chat.completion.chunks. */
1148
+ chatStream(options) {
1149
+ return this.client.chatStream({ ...options, bridge: this.cfg });
1150
+ }
1151
+ /** One-shot: send a string, get the assistant text. */
1152
+ async ask(message, extra) {
1153
+ const completion = await this.chat({
1154
+ messages: [{ role: "user", content: message }],
1155
+ ...extra
1156
+ });
1157
+ return completion.choices[0]?.message?.content || "";
1158
+ }
1159
+ /** One-shot: send a string, stream text deltas. */
1160
+ async *stream(message, extra) {
1161
+ for await (const chunk of this.chatStream({
1162
+ messages: [{ role: "user", content: message }],
1163
+ ...extra
1164
+ })) {
1165
+ const content = chunk.choices?.[0]?.delta?.content;
1166
+ if (content) yield content;
1167
+ }
1168
+ }
1169
+ /** Turn-based: send full message history, get assistant text. */
1170
+ async turn(messages, extra) {
1171
+ const completion = await this.chat({ messages, ...extra });
1172
+ return completion.choices[0]?.message?.content || "";
1173
+ }
1174
+ /** Clone with a new resume id — same harness, different logical conversation. */
1175
+ withResume(resume) {
1176
+ return new _BridgeSession(this.client, { ...this.cfg, resume });
1177
+ }
1178
+ /** Clone with a different model inside the same harness. */
1179
+ withModel(model) {
1180
+ return new _BridgeSession(this.client, { ...this.cfg, model });
1181
+ }
1182
+ /** The effective model id that will land on the router (`bridge/<harness>/<model>`). */
1183
+ get model() {
1184
+ return this.cfg.model ? `bridge/${this.cfg.harness}/${this.cfg.model}` : `bridge/${this.cfg.harness}`;
1185
+ }
1186
+ /** The resume id currently bound to this session, if any. */
1187
+ get resume() {
1188
+ return this.cfg.resume;
1189
+ }
1190
+ };
1191
+ function selectTiers(all, n) {
1192
+ if (n >= all.length) return [...all];
1193
+ if (n <= 1) return [all[0]];
1194
+ if (n === 2) return [all[0], all[all.length - 1]];
1195
+ const result = [all[0]];
1196
+ const step = (all.length - 1) / (n - 1);
1197
+ for (let i = 1; i < n - 1; i++) {
1198
+ result.push(all[Math.round(i * step)]);
1199
+ }
1200
+ result.push(all[all.length - 1]);
1201
+ return result;
1202
+ }
1203
+ function formatPrice(pricePerToken) {
1204
+ return `$${(pricePerToken * 1e3).toFixed(6)}/1K tokens`;
1205
+ }
1206
+ var TCloudError = class extends Error {
1207
+ constructor(status, message) {
1208
+ super(message);
1209
+ this.status = status;
1210
+ this.name = "TCloudError";
1211
+ }
1212
+ };
1213
+
1214
+ // src/instance.ts
1215
+ var MAX_LOG_LINES = 1e4;
1216
+ function appendLogLine(buffer, line, maxLines = MAX_LOG_LINES) {
1217
+ buffer.push(line);
1218
+ if (buffer.length > maxLines) {
1219
+ buffer.shift();
1220
+ }
1221
+ }
1222
+ var Instance = class _Instance {
1223
+ child;
1224
+ _config;
1225
+ _stopped = false;
1226
+ logBuffer;
1227
+ maxLogLines = MAX_LOG_LINES;
1228
+ constructor(child, config, logBuffer) {
1229
+ this.child = child;
1230
+ this._config = config;
1231
+ this.logBuffer = logBuffer;
1232
+ }
1233
+ /**
1234
+ * Start a new harness instance. Resolves once `cargo tangle harness up`
1235
+ * prints its "Harness up" marker, indicating all blueprints are healthy.
1236
+ *
1237
+ * Throws on timeout, process exit before ready, or missing `cargo-tangle`.
1238
+ */
1239
+ static async start(options = {}) {
1240
+ const cargoBinary = options.cargoBinary ?? "cargo-tangle";
1241
+ const timeoutMs = options.timeoutMs ?? 3e5;
1242
+ const routerUrl = options.routerUrl ?? "http://localhost:3000";
1243
+ const args = ["tangle", "harness", "up"];
1244
+ if (options.config) {
1245
+ args.push("--config", options.config);
1246
+ }
1247
+ if (options.only && options.only.length > 0) {
1248
+ args.push("--only", options.only.join(","));
1249
+ }
1250
+ if (options.includeAnvilLogs) {
1251
+ args.push("--include-anvil-logs");
1252
+ }
1253
+ const child = (0, import_node_child_process.spawn)(cargoBinary, args, {
1254
+ cwd: options.cwd ?? process.cwd(),
1255
+ env: process.env,
1256
+ stdio: ["ignore", "pipe", "pipe"]
1257
+ });
1258
+ const logBuffer = [];
1259
+ const pushLog = (line) => {
1260
+ appendLogLine(logBuffer, line, MAX_LOG_LINES);
1261
+ if (!options.quiet) {
1262
+ process.stdout.write(line + "\n");
1263
+ }
1264
+ };
1265
+ let stdoutTail = "";
1266
+ let stderrTail = "";
1267
+ child.stdout?.on("data", (chunk) => {
1268
+ stdoutTail += chunk.toString();
1269
+ const lines = stdoutTail.split("\n");
1270
+ stdoutTail = lines.pop() ?? "";
1271
+ for (const line of lines) pushLog(line);
1272
+ });
1273
+ child.stderr?.on("data", (chunk) => {
1274
+ stderrTail += chunk.toString();
1275
+ const lines = stderrTail.split("\n");
1276
+ stderrTail = lines.pop() ?? "";
1277
+ for (const line of lines) pushLog(line);
1278
+ });
1279
+ const blueprintNames = [];
1280
+ try {
1281
+ await new Promise((resolve, reject) => {
1282
+ let settled = false;
1283
+ let timer;
1284
+ let pollInterval;
1285
+ const cleanup = () => {
1286
+ if (settled) return;
1287
+ settled = true;
1288
+ if (pollInterval !== void 0) clearInterval(pollInterval);
1289
+ if (timer !== void 0) clearTimeout(timer);
1290
+ child.removeListener("exit", onExit);
1291
+ child.removeListener("error", onError);
1292
+ };
1293
+ const onExit = (code) => {
1294
+ if (settled) return;
1295
+ cleanup();
1296
+ reject(
1297
+ new Error(
1298
+ `cargo tangle harness exited with code ${code} before becoming ready. Last 20 lines:
1299
+ ${logBuffer.slice(-20).join("\n")}`
1300
+ )
1301
+ );
1302
+ };
1303
+ const onError = (err) => {
1304
+ if (settled) return;
1305
+ cleanup();
1306
+ reject(new Error(`Failed to spawn ${cargoBinary}: ${err.message}`));
1307
+ };
1308
+ timer = setTimeout(() => {
1309
+ if (settled) return;
1310
+ cleanup();
1311
+ reject(
1312
+ new Error(
1313
+ `Timed out after ${timeoutMs}ms waiting for harness to start. Last 20 lines:
1314
+ ${logBuffer.slice(-20).join("\n")}`
1315
+ )
1316
+ );
1317
+ }, timeoutMs);
1318
+ child.once("exit", onExit);
1319
+ child.once("error", onError);
1320
+ pollInterval = setInterval(() => {
1321
+ for (const line of logBuffer) {
1322
+ const match = line.match(/Harness up\.\s+(\d+)\s+blueprint/i);
1323
+ if (match) {
1324
+ if (settled) return;
1325
+ cleanup();
1326
+ resolve();
1327
+ return;
1328
+ }
1329
+ const bpMatch = line.match(/Starting blueprint-manager for '([^']+)'/);
1330
+ if (bpMatch) {
1331
+ blueprintNames.push(bpMatch[1]);
1332
+ }
1333
+ }
1334
+ }, 100);
1335
+ });
1336
+ } catch (err) {
1337
+ if (!child.killed) {
1338
+ child.kill("SIGTERM");
1339
+ }
1340
+ throw err;
1341
+ }
1342
+ return new _Instance(
1343
+ child,
1344
+ { routerUrl, blueprints: blueprintNames },
1345
+ logBuffer
1346
+ );
1347
+ }
1348
+ /** URL of the router serving this instance */
1349
+ get routerUrl() {
1350
+ return this._config.routerUrl;
1351
+ }
1352
+ /** Names of blueprints that were started */
1353
+ get blueprints() {
1354
+ return [...this._config.blueprints];
1355
+ }
1356
+ /**
1357
+ * Create a pre-configured {@link TCloudClient} pointed at this instance's router.
1358
+ * Merges any passed config with the instance's routerUrl (instance wins).
1359
+ */
1360
+ client(config = {}) {
1361
+ return new TCloudClient({
1362
+ ...config,
1363
+ baseURL: this._config.routerUrl
1364
+ });
1365
+ }
1366
+ /** Return the last N log lines from the harness */
1367
+ logs(lines = 100) {
1368
+ return this.logBuffer.slice(-lines);
1369
+ }
1370
+ /** Whether the harness process is still running */
1371
+ get isRunning() {
1372
+ return !this._stopped && !this.child.killed && this.child.exitCode === null;
1373
+ }
1374
+ /**
1375
+ * Stop the harness. Sends SIGTERM, waits up to 10s for clean shutdown,
1376
+ * then SIGKILL.
1377
+ */
1378
+ async stop(timeoutMs = 1e4) {
1379
+ if (this._stopped) return;
1380
+ this._stopped = true;
1381
+ if (this.child.exitCode !== null) {
1382
+ return;
1383
+ }
1384
+ this.child.kill("SIGTERM");
1385
+ await new Promise((resolve) => {
1386
+ const timer = setTimeout(() => {
1387
+ if (this.child.exitCode === null) {
1388
+ this.child.kill("SIGKILL");
1389
+ }
1390
+ resolve();
1391
+ }, timeoutMs);
1392
+ this.child.once("exit", () => {
1393
+ clearTimeout(timer);
1394
+ resolve();
1395
+ });
1396
+ });
1397
+ }
1398
+ };
1399
+ function writeTempHarnessConfig(blueprints) {
1400
+ const dir = (0, import_node_path.join)(
1401
+ (0, import_node_os.tmpdir)(),
1402
+ `tangle-harness-${process.pid}-${Date.now()}-${(0, import_node_crypto.randomUUID)()}`
1403
+ );
1404
+ if (!(0, import_node_fs.existsSync)(dir)) {
1405
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
1406
+ }
1407
+ const file = (0, import_node_path.join)(dir, "harness.toml");
1408
+ const lines = [];
1409
+ lines.push("[chain]");
1410
+ lines.push("anvil = true");
1411
+ lines.push("");
1412
+ for (const bp of blueprints) {
1413
+ lines.push("[[blueprint]]");
1414
+ lines.push(`name = "${bp.name}"`);
1415
+ lines.push(`path = "${bp.path}"`);
1416
+ if (bp.port !== void 0) {
1417
+ lines.push(`port = ${bp.port}`);
1418
+ }
1419
+ if (bp.env) {
1420
+ for (const [k, v] of Object.entries(bp.env)) {
1421
+ lines.push(`env.${k} = "${v.replace(/"/g, '\\"')}"`);
1422
+ }
1423
+ }
1424
+ lines.push("");
1425
+ }
1426
+ (0, import_node_fs.writeFileSync)(file, lines.join("\n"));
1427
+ return file;
1428
+ }
1429
+ // Annotate the CommonJS export names for ESM import in node:
1430
+ 0 && (module.exports = {
1431
+ Instance,
1432
+ appendLogLine,
1433
+ writeTempHarnessConfig
1434
+ });