@tangle-network/tcloud 0.1.4 → 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/dist/shielded.cjs CHANGED
@@ -39,6 +39,219 @@ module.exports = __toCommonJS(shielded_exports);
39
39
  var import_accounts = require("viem/accounts");
40
40
  var import_viem = require("viem");
41
41
 
42
+ // src/private-router.ts
43
+ function secureRandom() {
44
+ const arr = new Uint32Array(1);
45
+ crypto.getRandomValues(arr);
46
+ return arr[0] / (4294967295 + 1);
47
+ }
48
+ var PrivateRouter = class {
49
+ config;
50
+ operators = [];
51
+ usage = /* @__PURE__ */ new Map();
52
+ currentIndex = 0;
53
+ totalRequests = 0;
54
+ constructor(config = {}) {
55
+ this.config = {
56
+ strategy: config.strategy || "round-robin",
57
+ maxRequestsPerOperator: config.maxRequestsPerOperator || 5,
58
+ minOperators: config.minOperators || 3,
59
+ preferRegions: config.preferRegions,
60
+ excludeOperators: config.excludeOperators,
61
+ summarizeOnSwitch: config.summarizeOnSwitch ?? false
62
+ };
63
+ }
64
+ /** Set the available operator pool */
65
+ setOperators(operators) {
66
+ let filtered = operators.filter(
67
+ (o) => !this.config.excludeOperators?.includes(o.slug)
68
+ );
69
+ if (this.config.preferRegions?.length) {
70
+ filtered.sort((a, b) => {
71
+ const aPreferred = this.config.preferRegions.includes(a.region) ? 0 : 1;
72
+ const bPreferred = this.config.preferRegions.includes(b.region) ? 0 : 1;
73
+ return aPreferred - bPreferred;
74
+ });
75
+ }
76
+ this.operators = filtered;
77
+ }
78
+ /** Select the next operator for a request */
79
+ selectOperator(model) {
80
+ const eligible = this.operators.filter((o) => o.models.includes(model));
81
+ if (eligible.length === 0) return null;
82
+ if (eligible.length < this.config.minOperators) {
83
+ console.warn(
84
+ `[PrivateRouter] Only ${eligible.length} eligible operator(s) for model "${model}", but minOperators requires ${this.config.minOperators}. Refusing to route.`
85
+ );
86
+ return null;
87
+ }
88
+ this.totalRequests++;
89
+ switch (this.config.strategy) {
90
+ case "round-robin":
91
+ return this.roundRobin(eligible);
92
+ case "random":
93
+ return this.random(eligible);
94
+ case "geo-distributed":
95
+ return this.geoDistributed(eligible);
96
+ case "min-exposure":
97
+ return this.minExposure(eligible);
98
+ case "latency-aware":
99
+ return this.latencyAware(eligible);
100
+ default:
101
+ return this.roundRobin(eligible);
102
+ }
103
+ }
104
+ /** Should we summarize context before this request? (operator is changing) */
105
+ shouldSummarize(model) {
106
+ if (!this.config.summarizeOnSwitch) return false;
107
+ const next = this.peekNextOperator(model);
108
+ const last = this.getLastUsedOperator();
109
+ return next !== null && last !== null && next.slug !== last.slug;
110
+ }
111
+ /** Get privacy stats */
112
+ getStats() {
113
+ return {
114
+ totalRequests: this.totalRequests,
115
+ operatorsUsed: this.usage.size,
116
+ operatorBreakdown: Array.from(this.usage.values()).map((u) => ({
117
+ slug: u.slug,
118
+ requests: u.requestCount,
119
+ lastUsed: u.lastUsedAt
120
+ })),
121
+ strategy: this.config.strategy
122
+ };
123
+ }
124
+ // ─── Strategies ────────────────────────────────────────────
125
+ roundRobin(eligible) {
126
+ const op = eligible[this.currentIndex % eligible.length];
127
+ this.currentIndex++;
128
+ this.recordUsage(op);
129
+ return op;
130
+ }
131
+ random(eligible) {
132
+ const idx = Math.floor(secureRandom() * eligible.length);
133
+ const op = eligible[idx];
134
+ this.recordUsage(op);
135
+ return op;
136
+ }
137
+ geoDistributed(eligible) {
138
+ const regionUsage = /* @__PURE__ */ new Map();
139
+ for (const op2 of eligible) {
140
+ const usage = this.usage.get(op2.slug)?.requestCount || 0;
141
+ const current = regionUsage.get(op2.region) || 0;
142
+ regionUsage.set(op2.region, current + usage);
143
+ }
144
+ const sortedRegions = [...regionUsage.entries()].sort((a, b) => a[1] - b[1]);
145
+ const targetRegion = sortedRegions[0]?.[0];
146
+ const regionOps = eligible.filter((o) => o.region === targetRegion);
147
+ const op = regionOps[Math.floor(secureRandom() * regionOps.length)] || eligible[0];
148
+ this.recordUsage(op);
149
+ return op;
150
+ }
151
+ minExposure(eligible) {
152
+ const lastUsed = this.getLastUsedOperator();
153
+ if (lastUsed) {
154
+ const lastUsage = this.usage.get(lastUsed.slug);
155
+ const others = eligible.filter((o) => o.slug !== lastUsed.slug);
156
+ if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) {
157
+ const sorted2 = others.sort(
158
+ (a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
159
+ );
160
+ const op2 = sorted2[0];
161
+ this.recordUsage(op2);
162
+ return op2;
163
+ }
164
+ }
165
+ const sorted = [...eligible].sort(
166
+ (a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
167
+ );
168
+ const op = sorted[0];
169
+ this.recordUsage(op);
170
+ return op;
171
+ }
172
+ latencyAware(eligible) {
173
+ const weights = eligible.map((o) => {
174
+ const latencyWeight = 1 / Math.max(o.avgLatencyMs, 10);
175
+ const usagePenalty = (this.usage.get(o.slug)?.requestCount || 0) * 0.1;
176
+ return Math.max(latencyWeight - usagePenalty, 0.01);
177
+ });
178
+ const totalWeight = weights.reduce((s, w) => s + w, 0);
179
+ let r = secureRandom() * totalWeight;
180
+ for (let i = 0; i < eligible.length; i++) {
181
+ r -= weights[i];
182
+ if (r <= 0) {
183
+ this.recordUsage(eligible[i]);
184
+ return eligible[i];
185
+ }
186
+ }
187
+ const op = eligible[eligible.length - 1];
188
+ this.recordUsage(op);
189
+ return op;
190
+ }
191
+ // ─── Helpers ───────────────────────────────────────────────
192
+ recordUsage(op) {
193
+ const existing = this.usage.get(op.slug);
194
+ this.usage.set(op.slug, {
195
+ slug: op.slug,
196
+ requestCount: (existing?.requestCount || 0) + 1,
197
+ lastUsedAt: Date.now()
198
+ });
199
+ }
200
+ getLastUsedOperator() {
201
+ let latest = null;
202
+ for (const u of this.usage.values()) {
203
+ if (!latest || u.lastUsedAt > latest.lastUsedAt) latest = u;
204
+ }
205
+ if (!latest) return null;
206
+ return this.operators.find((o) => o.slug === latest.slug) || null;
207
+ }
208
+ peekNextOperator(model) {
209
+ const eligible = this.operators.filter((o) => o.models.includes(model));
210
+ if (eligible.length === 0) return null;
211
+ if (eligible.length < this.config.minOperators) return null;
212
+ const last = this.getLastUsedOperator();
213
+ switch (this.config.strategy) {
214
+ case "round-robin":
215
+ return eligible[this.currentIndex % eligible.length];
216
+ case "min-exposure": {
217
+ if (last) {
218
+ const lastUsage = this.usage.get(last.slug);
219
+ const others = eligible.filter((o) => o.slug !== last.slug);
220
+ if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) {
221
+ const sorted2 = others.sort(
222
+ (a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
223
+ );
224
+ return sorted2[0];
225
+ }
226
+ }
227
+ const sorted = [...eligible].sort(
228
+ (a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0)
229
+ );
230
+ return sorted[0];
231
+ }
232
+ case "geo-distributed": {
233
+ const regionUsage = /* @__PURE__ */ new Map();
234
+ for (const op of eligible) {
235
+ const usage = this.usage.get(op.slug)?.requestCount || 0;
236
+ const current = regionUsage.get(op.region) || 0;
237
+ regionUsage.set(op.region, current + usage);
238
+ }
239
+ const sortedRegions = [...regionUsage.entries()].sort((a, b) => a[1] - b[1]);
240
+ const targetRegion = sortedRegions[0]?.[0];
241
+ const regionOps = eligible.filter((o) => o.region === targetRegion);
242
+ return regionOps[0] || eligible[0];
243
+ }
244
+ case "random":
245
+ case "latency-aware":
246
+ default:
247
+ if (last && eligible.length > 1) {
248
+ return eligible.find((o) => o.slug !== last.slug) || eligible[0];
249
+ }
250
+ return eligible[0];
251
+ }
252
+ }
253
+ };
254
+
42
255
  // src/client.ts
43
256
  var DEFAULT_BASE_URL = "https://router.tangle.tools/v1";
44
257
  async function proxiedFetch(privacy, url, init, streaming) {
@@ -79,7 +292,15 @@ async function proxiedFetch(privacy, url, init, streaming) {
79
292
  }
80
293
  return fetch(url, init);
81
294
  }
82
- var TCloudClient = class {
295
+ var DEFAULT_RETRY = {
296
+ maxRetries: 3,
297
+ initialBackoffMs: 500,
298
+ maxBackoffMs: 3e4,
299
+ multiplier: 2,
300
+ retryableStatuses: [429, 500, 502, 503, 504]
301
+ };
302
+ var DEFAULT_TIMEOUT_MS = 6e4;
303
+ var TCloudClient = class _TCloudClient {
83
304
  baseURL;
84
305
  apiKey;
85
306
  model;
@@ -87,17 +308,25 @@ var TCloudClient = class {
87
308
  spendAuthFn;
88
309
  privacy;
89
310
  limits;
311
+ retryConfig;
312
+ timeoutMs;
90
313
  _totalSpent = 0;
91
314
  _requestCount = 0;
315
+ privateRouter;
316
+ _cachedOperators = [];
317
+ _operatorsCachedAt = 0;
318
+ static OPERATORS_TTL_MS = 5 * 60 * 1e3;
92
319
  constructor(config = {}) {
93
320
  this.baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/$/, "");
94
- this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY || process.env.OPENAI_API_KEY;
321
+ this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY;
95
322
  this.model = config.model || "gpt-4o-mini";
96
323
  this.privacy = config.privacy;
97
324
  this.limits = config.limits;
325
+ this.retryConfig = config.retry === false ? null : { ...DEFAULT_RETRY, ...config.retry };
326
+ this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
98
327
  this.headers = {
99
328
  "Content-Type": "application/json",
100
- "X-Tangle-Client": "tcloud-sdk/0.1.4"
329
+ "X-Tangle-Client": "tcloud-sdk/0.2.0"
101
330
  };
102
331
  if (this.apiKey) {
103
332
  this.headers["Authorization"] = `Bearer ${this.apiKey}`;
@@ -117,6 +346,17 @@ var TCloudClient = class {
117
346
  if (config.routing?.region) {
118
347
  this.headers["X-Tangle-Region"] = config.routing.region;
119
348
  }
349
+ if (config.routing?.strategy) {
350
+ const strategyMap = {
351
+ "round-robin": "round-robin",
352
+ "lowest-latency": "latency-aware",
353
+ "lowest-price": "round-robin",
354
+ "highest-reputation": "round-robin"
355
+ };
356
+ this.privateRouter = new PrivateRouter({
357
+ strategy: strategyMap[config.routing.strategy] || "round-robin"
358
+ });
359
+ }
120
360
  }
121
361
  /** Set the SpendAuth signer for private mode */
122
362
  setSpendAuthSigner(fn) {
@@ -150,6 +390,25 @@ var TCloudClient = class {
150
390
  if (pct >= 0.8) this.limits.onLimitWarning({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
151
391
  }
152
392
  }
393
+ /** Ensure the private router has operators loaded (with TTL-based caching) */
394
+ async ensureRouterOperators() {
395
+ if (!this.privateRouter) return;
396
+ const now = Date.now();
397
+ if (this._cachedOperators.length > 0 && now - this._operatorsCachedAt < _TCloudClient.OPERATORS_TTL_MS) {
398
+ return;
399
+ }
400
+ const data = await this.operators();
401
+ this._cachedOperators = (data.operators || []).map((op) => ({
402
+ slug: op.slug,
403
+ endpointUrl: op.endpointUrl,
404
+ region: "",
405
+ reputationScore: op.reputationScore,
406
+ avgLatencyMs: op.avgLatencyMs,
407
+ models: op.models.map((m) => m.modelId)
408
+ }));
409
+ this._operatorsCachedAt = now;
410
+ this.privateRouter.setOperators(this._cachedOperators);
411
+ }
153
412
  /** Track cost after a response, using actual pricing from response headers when available */
154
413
  trackCost(completion, res) {
155
414
  this._requestCount++;
@@ -169,36 +428,133 @@ var TCloudClient = class {
169
428
  }
170
429
  }
171
430
  }
172
- /** Chat completion (non-streaming) */
173
- async chat(options) {
431
+ /**
432
+ * Core fetch with retry + timeout. All helpers build on this.
433
+ * Retries on retryable status codes with exponential backoff + jitter.
434
+ */
435
+ async _doFetch(url, init, streaming) {
436
+ const retry = this.retryConfig;
437
+ const maxAttempts = retry ? retry.maxRetries + 1 : 1;
438
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
439
+ const controller = new AbortController();
440
+ let timer;
441
+ if (this.timeoutMs > 0 && !streaming) {
442
+ timer = setTimeout(() => controller.abort(), this.timeoutMs);
443
+ }
444
+ try {
445
+ const res = await proxiedFetch(this.privacy, url, {
446
+ ...init,
447
+ signal: controller.signal
448
+ }, streaming);
449
+ if (res.ok) return res;
450
+ if (retry && attempt < retry.maxRetries && retry.retryableStatuses.includes(res.status)) {
451
+ const backoff = Math.min(
452
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
453
+ retry.maxBackoffMs
454
+ );
455
+ const jitter = backoff * 0.5 * Math.random();
456
+ await new Promise((r) => setTimeout(r, backoff + jitter));
457
+ continue;
458
+ }
459
+ const err = await res.json().catch(() => ({ error: res.statusText }));
460
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
461
+ } catch (e) {
462
+ if (e instanceof TCloudError) throw e;
463
+ if (retry && attempt < retry.maxRetries) {
464
+ const backoff = Math.min(
465
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
466
+ retry.maxBackoffMs
467
+ );
468
+ await new Promise((r) => setTimeout(r, backoff));
469
+ continue;
470
+ }
471
+ if (e?.name === "AbortError") {
472
+ throw new TCloudError(408, `Request timed out after ${this.timeoutMs}ms`);
473
+ }
474
+ throw new TCloudError(0, e?.message || "Network error");
475
+ } finally {
476
+ if (timer !== void 0) clearTimeout(timer);
477
+ }
478
+ }
479
+ throw new TCloudError(0, "Retry loop exhausted");
480
+ }
481
+ /**
482
+ * Shared request helper for billable JSON API calls.
483
+ * Enforces: checkLimits → fetch with retry/timeout → error parsing → requestCount.
484
+ */
485
+ async _request(url, init = {}) {
174
486
  this.checkLimits();
487
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
488
+ this._requestCount++;
489
+ return res.json();
490
+ }
491
+ /**
492
+ * Shared request helper for read-only/non-billable JSON API calls.
493
+ * No limits check, no request counting.
494
+ */
495
+ async _fetch(url, init = {}) {
496
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
497
+ return res.json();
498
+ }
499
+ /**
500
+ * Shared request helper for billable calls that return non-JSON (e.g. ArrayBuffer).
501
+ */
502
+ async _requestRaw(url, init = {}) {
503
+ this.checkLimits();
504
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
505
+ this._requestCount++;
506
+ return res;
507
+ }
508
+ /**
509
+ * Prepare headers for chat requests — operator routing + SpendAuth.
510
+ * Shared between chat() and chatStream() to eliminate duplication.
511
+ */
512
+ async _prepareChatRequest(model) {
175
513
  const headers = { ...this.headers };
176
514
  if (this.spendAuthFn) {
177
515
  const auth = await this.spendAuthFn();
178
516
  headers["X-Payment-Signature"] = JSON.stringify(auth);
179
517
  delete headers["Authorization"];
180
518
  }
181
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/chat/completions`, {
519
+ let baseURL = this.baseURL;
520
+ if (this.privateRouter) {
521
+ await this.ensureRouterOperators();
522
+ const operator = this.privateRouter.selectOperator(model);
523
+ if (operator) {
524
+ baseURL = operator.endpointUrl.replace(/\/$/, "");
525
+ headers["X-Tangle-Operator"] = operator.slug;
526
+ delete headers["Authorization"];
527
+ }
528
+ }
529
+ return { headers, baseURL };
530
+ }
531
+ /** Build the chat completions request body */
532
+ _chatBody(options, stream) {
533
+ return JSON.stringify({
534
+ model: options.model || this.model,
535
+ messages: options.messages,
536
+ temperature: options.temperature,
537
+ max_tokens: options.maxTokens,
538
+ stream,
539
+ stop: options.stop,
540
+ top_p: options.topP,
541
+ frequency_penalty: options.frequencyPenalty,
542
+ presence_penalty: options.presencePenalty,
543
+ response_format: options.responseFormat,
544
+ tools: options.tools,
545
+ tool_choice: options.toolChoice,
546
+ ...options.providerOptions
547
+ });
548
+ }
549
+ /** Chat completion (non-streaming) */
550
+ async chat(options) {
551
+ this.checkLimits();
552
+ const { headers, baseURL } = await this._prepareChatRequest(options.model || this.model);
553
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
182
554
  method: "POST",
183
555
  headers,
184
- body: JSON.stringify({
185
- model: options.model || this.model,
186
- messages: options.messages,
187
- temperature: options.temperature,
188
- max_tokens: options.maxTokens,
189
- stream: false,
190
- stop: options.stop,
191
- top_p: options.topP,
192
- frequency_penalty: options.frequencyPenalty,
193
- presence_penalty: options.presencePenalty,
194
- response_format: options.responseFormat,
195
- tools: options.tools
196
- })
556
+ body: this._chatBody(options, false)
197
557
  }, false);
198
- if (!res.ok) {
199
- const err = await res.json().catch(() => ({ error: res.statusText }));
200
- throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
201
- }
202
558
  const completion = await res.json();
203
559
  this.trackCost(completion, res);
204
560
  return completion;
@@ -206,29 +562,13 @@ var TCloudClient = class {
206
562
  /** Chat completion (streaming) — returns an async iterator of chunks */
207
563
  async *chatStream(options) {
208
564
  this.checkLimits();
209
- const headers = { ...this.headers };
210
- if (this.spendAuthFn) {
211
- const auth = await this.spendAuthFn();
212
- headers["X-Payment-Signature"] = JSON.stringify(auth);
213
- delete headers["Authorization"];
214
- }
215
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/chat/completions`, {
565
+ this._requestCount++;
566
+ const { headers, baseURL } = await this._prepareChatRequest(options.model || this.model);
567
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
216
568
  method: "POST",
217
569
  headers,
218
- body: JSON.stringify({
219
- model: options.model || this.model,
220
- messages: options.messages,
221
- temperature: options.temperature,
222
- max_tokens: options.maxTokens,
223
- stream: true,
224
- stop: options.stop,
225
- top_p: options.topP
226
- })
570
+ body: this._chatBody(options, true)
227
571
  }, true);
228
- if (!res.ok) {
229
- const err = await res.json().catch(() => ({ error: res.statusText }));
230
- throw new TCloudError(res.status, err.error || err.message || res.statusText);
231
- }
232
572
  const reader = res.body.getReader();
233
573
  const decoder = new TextDecoder();
234
574
  let buf = "";
@@ -236,13 +576,13 @@ var TCloudClient = class {
236
576
  const { done, value } = await reader.read();
237
577
  if (done) break;
238
578
  buf += decoder.decode(value, { stream: true });
579
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
239
580
  const lines = buf.split("\n");
240
581
  buf = lines.pop() || "";
241
582
  for (const line of lines) {
242
583
  if (!line.startsWith("data: ")) continue;
243
584
  const data = line.slice(6).trim();
244
585
  if (data === "[DONE]") {
245
- this._requestCount++;
246
586
  return;
247
587
  }
248
588
  try {
@@ -282,53 +622,39 @@ var TCloudClient = class {
282
622
  }
283
623
  /** List available models */
284
624
  async models() {
285
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/models`, { headers: this.headers }, false);
286
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch models");
287
- const data = await res.json();
625
+ const data = await this._fetch(`${this.baseURL}/models`);
288
626
  return data.data || [];
289
627
  }
290
628
  /** List active operators */
291
629
  async operators() {
292
630
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
293
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/operators`, { headers: this.headers }, false);
294
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch operators");
295
- return res.json();
631
+ return this._fetch(`${apiRoot}/api/operators`);
296
632
  }
297
633
  /** Get credit balance */
298
634
  async credits() {
299
635
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
300
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/billing`, { headers: this.headers }, false);
301
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch credits");
302
- return res.json();
636
+ return this._fetch(`${apiRoot}/api/billing`);
303
637
  }
304
638
  /** Add credits */
305
639
  async addCredits(amount) {
306
640
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
307
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/billing`, {
641
+ return this._fetch(`${apiRoot}/api/billing`, {
308
642
  method: "POST",
309
- headers: this.headers,
310
643
  body: JSON.stringify({ amount })
311
- }, false);
312
- if (!res.ok) throw new TCloudError(res.status, "Failed to add credits");
313
- return res.json();
644
+ });
314
645
  }
315
646
  /** Create a new API key */
316
647
  async createKey(name) {
317
648
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
318
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys`, {
649
+ return this._fetch(`${apiRoot}/api/keys`, {
319
650
  method: "POST",
320
- headers: this.headers,
321
651
  body: JSON.stringify({ name })
322
- }, false);
323
- if (!res.ok) throw new TCloudError(res.status, "Failed to create API key");
324
- return res.json();
652
+ });
325
653
  }
326
654
  /** List API keys */
327
655
  async keys() {
328
656
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
329
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys`, { headers: this.headers }, false);
330
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch keys");
331
- return res.json();
657
+ return this._fetch(`${apiRoot}/api/keys`);
332
658
  }
333
659
  /** Revoke an API key */
334
660
  async revokeKey(id) {
@@ -337,30 +663,25 @@ var TCloudClient = class {
337
663
  method: "DELETE",
338
664
  headers: this.headers
339
665
  }, false);
340
- if (!res.ok) throw new TCloudError(res.status, "Failed to revoke key");
666
+ if (!res.ok) {
667
+ const err = await res.json().catch(() => ({ error: res.statusText }));
668
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
669
+ }
341
670
  }
342
671
  /** Generate embeddings */
343
672
  async embeddings(options) {
344
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/embeddings`, {
673
+ return this._request(`${this.baseURL}/embeddings`, {
345
674
  method: "POST",
346
- headers: this.headers,
347
675
  body: JSON.stringify({
348
676
  model: options.model || "text-embedding-3-small",
349
677
  input: options.input
350
678
  })
351
- }, false);
352
- if (!res.ok) {
353
- const err = await res.json().catch(() => ({ error: res.statusText }));
354
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
355
- }
356
- this._requestCount++;
357
- return res.json();
679
+ });
358
680
  }
359
681
  /** Generate images */
360
682
  async imageGenerate(options) {
361
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/images/generations`, {
683
+ return this._request(`${this.baseURL}/images/generations`, {
362
684
  method: "POST",
363
- headers: this.headers,
364
685
  body: JSON.stringify({
365
686
  model: options.model || "dall-e-3",
366
687
  prompt: options.prompt,
@@ -369,56 +690,36 @@ var TCloudClient = class {
369
690
  quality: options.quality,
370
691
  response_format: options.response_format
371
692
  })
372
- }, false);
373
- if (!res.ok) {
374
- const err = await res.json().catch(() => ({ error: res.statusText }));
375
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
376
- }
377
- this._requestCount++;
378
- return res.json();
693
+ });
379
694
  }
380
695
  /** Rerank documents by relevance to a query */
381
696
  async rerank(options) {
382
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/rerank`, {
697
+ return this._request(`${this.baseURL}/rerank`, {
383
698
  method: "POST",
384
- headers: this.headers,
385
699
  body: JSON.stringify({
386
700
  model: options.model || "rerank-english-v3.0",
387
701
  query: options.query,
388
702
  documents: options.documents,
389
703
  top_n: options.top_n
390
704
  })
391
- }, false);
392
- if (!res.ok) {
393
- const err = await res.json().catch(() => ({ error: res.statusText }));
394
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
395
- }
396
- this._requestCount++;
397
- return res.json();
705
+ });
398
706
  }
399
707
  /** Text-to-speech */
400
708
  async speech(options) {
401
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/speech`, {
709
+ const res = await this._requestRaw(`${this.baseURL}/audio/speech`, {
402
710
  method: "POST",
403
- headers: this.headers,
404
711
  body: JSON.stringify({
405
712
  model: options.model || "tts-1",
406
713
  input: options.input,
407
714
  voice: options.voice || "alloy"
408
715
  })
409
- }, false);
410
- if (!res.ok) {
411
- const err = await res.json().catch(() => ({ error: res.statusText }));
412
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
413
- }
414
- this._requestCount++;
716
+ });
415
717
  return res.arrayBuffer();
416
718
  }
417
719
  /** Legacy completions endpoint */
418
720
  async completions(options) {
419
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/completions`, {
721
+ return this._request(`${this.baseURL}/completions`, {
420
722
  method: "POST",
421
- headers: this.headers,
422
723
  body: JSON.stringify({
423
724
  model: options.model || this.model,
424
725
  prompt: options.prompt,
@@ -427,13 +728,7 @@ var TCloudClient = class {
427
728
  stop: options.stop,
428
729
  top_p: options.topP
429
730
  })
430
- }, false);
431
- if (!res.ok) {
432
- const err = await res.json().catch(() => ({ error: res.statusText }));
433
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
434
- }
435
- this._requestCount++;
436
- return res.json();
731
+ });
437
732
  }
438
733
  /** Audio transcription (speech-to-text) */
439
734
  async transcribe(file, options) {
@@ -444,6 +739,7 @@ var TCloudClient = class {
444
739
  if (options?.prompt) formData.append("prompt", options.prompt);
445
740
  const headers = { ...this.headers };
446
741
  delete headers["Content-Type"];
742
+ this.checkLimits();
447
743
  const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/transcriptions`, {
448
744
  method: "POST",
449
745
  headers,
@@ -451,75 +747,181 @@ var TCloudClient = class {
451
747
  }, false);
452
748
  if (!res.ok) {
453
749
  const err = await res.json().catch(() => ({ error: res.statusText }));
454
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
750
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
455
751
  }
456
752
  this._requestCount++;
457
753
  return res.json();
458
754
  }
459
755
  /** Create a fine-tuning job */
460
756
  async fineTuneCreate(options) {
461
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/fine_tuning/jobs`, {
757
+ return this._request(`${this.baseURL}/fine_tuning/jobs`, {
462
758
  method: "POST",
463
- headers: this.headers,
464
759
  body: JSON.stringify(options)
465
- }, false);
466
- if (!res.ok) {
467
- const err = await res.json().catch(() => ({ error: res.statusText }));
468
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
469
- }
470
- this._requestCount++;
471
- return res.json();
760
+ });
472
761
  }
473
762
  /** List fine-tuning jobs */
474
763
  async fineTuneList() {
475
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/fine_tuning/jobs`, {
476
- headers: this.headers
477
- }, false);
478
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch fine-tuning jobs");
479
- return res.json();
764
+ return this._fetch(`${this.baseURL}/fine_tuning/jobs`);
480
765
  }
481
766
  /** Submit a batch of chat requests */
482
767
  async batch(requests) {
483
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/batch`, {
768
+ return this._request(`${this.baseURL}/batch`, {
484
769
  method: "POST",
485
- headers: this.headers,
486
770
  body: JSON.stringify({ requests })
487
- }, false);
488
- if (!res.ok) {
489
- const err = await res.json().catch(() => ({ error: res.statusText }));
490
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
491
- }
492
- return res.json();
771
+ });
493
772
  }
494
773
  /** Get batch job status */
495
774
  async batchStatus(jobId) {
496
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/batch?id=${jobId}`, {
497
- headers: this.headers
498
- }, false);
499
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch batch status");
500
- return res.json();
775
+ return this._fetch(`${this.baseURL}/batch?id=${jobId}`);
501
776
  }
502
777
  /** Generate video */
503
778
  async videoGenerate(options) {
504
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/video/generate`, {
779
+ return this._request(`${this.baseURL}/video/generate`, {
505
780
  method: "POST",
506
- headers: this.headers,
507
781
  body: JSON.stringify(options)
508
- }, false);
509
- if (!res.ok) {
510
- const err = await res.json().catch(() => ({ error: res.statusText }));
511
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
512
- }
513
- this._requestCount++;
514
- return res.json();
782
+ });
515
783
  }
516
784
  /** Get video generation status */
517
785
  async videoStatus(id) {
518
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/video?id=${id}`, {
519
- headers: this.headers
520
- }, false);
521
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch video status");
522
- return res.json();
786
+ return this._fetch(`${this.baseURL}/video?id=${id}`);
787
+ }
788
+ /** Generate an avatar video (lip-synced talking head from audio + face image).
789
+ * Returns 202 with a job_id for async polling via avatarJobStatus(). */
790
+ async avatarGenerate(options) {
791
+ return this._request(`${this.baseURL}/avatar/generate`, {
792
+ method: "POST",
793
+ body: JSON.stringify(options)
794
+ });
795
+ }
796
+ /** Poll an avatar generation job by ID. */
797
+ async avatarJobStatus(jobId) {
798
+ return this._fetch(`${this.baseURL}/avatar/jobs/${jobId}`);
799
+ }
800
+ /** Poll an avatar job until it reaches a terminal state (completed/failed).
801
+ * Returns the final job status. Throws on failure. */
802
+ async pollAvatarJob(jobId, options) {
803
+ const interval = options?.intervalMs ?? 5e3;
804
+ const timeout = options?.timeoutMs ?? 3e5;
805
+ const deadline = Date.now() + timeout;
806
+ while (Date.now() < deadline) {
807
+ const job = await this.avatarJobStatus(jobId);
808
+ if (job.status === "completed") return job;
809
+ if (job.status === "failed") {
810
+ throw new TCloudError(500, job.error || `Avatar job ${jobId} failed`);
811
+ }
812
+ await new Promise((r) => setTimeout(r, interval));
813
+ }
814
+ throw new TCloudError(408, `Avatar job ${jobId} timed out after ${timeout}ms`);
815
+ }
816
+ /**
817
+ * Watch an async job via SSE until it reaches a terminal state.
818
+ * Works with avatar, video, and training blueprint operators.
819
+ *
820
+ * @param jobId - The job ID returned by the creation endpoint
821
+ * @param options - Optional: operatorUrl override, onEvent callback
822
+ * @returns The final JobEvent (completed/failed/cancelled)
823
+ */
824
+ async watchJob(jobId, options) {
825
+ const base = options?.operatorUrl?.replace(/\/$/, "") || this.baseURL;
826
+ const url = `${base}/v1/jobs/${encodeURIComponent(jobId)}/events`;
827
+ const timeout = options?.timeout ?? 3e5;
828
+ const controller = new AbortController();
829
+ const timer = setTimeout(() => controller.abort(), timeout);
830
+ try {
831
+ const watchHeaders = {
832
+ ...this.headers,
833
+ Accept: "text/event-stream"
834
+ };
835
+ if (options?.operatorUrl) {
836
+ delete watchHeaders["Authorization"];
837
+ }
838
+ if (options?.sseToken) {
839
+ watchHeaders["Authorization"] = `Bearer ${options.sseToken}`;
840
+ }
841
+ const res = await proxiedFetch(this.privacy, url, {
842
+ headers: watchHeaders,
843
+ signal: controller.signal
844
+ }, true);
845
+ if (!res.ok) {
846
+ const err = await res.json().catch(() => ({ error: res.statusText }));
847
+ throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
848
+ }
849
+ const reader = res.body.getReader();
850
+ const decoder = new TextDecoder();
851
+ let buf = "";
852
+ const terminalStatuses = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
853
+ while (true) {
854
+ const { done, value } = await reader.read();
855
+ if (done) {
856
+ throw new TCloudError(502, `SSE stream ended without terminal event for job ${jobId}`);
857
+ }
858
+ buf += decoder.decode(value, { stream: true });
859
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
860
+ const lines = buf.split("\n");
861
+ buf = lines.pop() || "";
862
+ for (const line of lines) {
863
+ if (!line.startsWith("data: ")) continue;
864
+ const data = line.slice(6).trim();
865
+ if (!data || data === "[DONE]") continue;
866
+ let event;
867
+ try {
868
+ event = JSON.parse(data);
869
+ } catch {
870
+ continue;
871
+ }
872
+ try {
873
+ options?.onEvent?.(event);
874
+ } catch (cbErr) {
875
+ console.error("watchJob onEvent callback error:", cbErr);
876
+ }
877
+ if (terminalStatuses.has(event.status)) {
878
+ return event;
879
+ }
880
+ }
881
+ }
882
+ } catch (err) {
883
+ if (err?.name === "AbortError") {
884
+ throw new TCloudError(408, `Job ${jobId} timed out after ${timeout}ms`);
885
+ }
886
+ throw err;
887
+ } finally {
888
+ clearTimeout(timer);
889
+ }
890
+ }
891
+ // ---------------------------------------------------------------------------
892
+ // Vector Store (requires operator routing — X-Tangle-Service/Blueprint/Operator)
893
+ // ---------------------------------------------------------------------------
894
+ /** Create a vector collection on the operator's vector store */
895
+ async createCollection(options) {
896
+ return this._request(`${this.baseURL}/collections`, {
897
+ method: "POST",
898
+ body: JSON.stringify(options)
899
+ });
900
+ }
901
+ /** List collections on the operator's vector store */
902
+ async listCollections() {
903
+ return this._fetch(`${this.baseURL}/collections`);
904
+ }
905
+ /** Upsert vectors into a collection */
906
+ async upsertVectors(collection, vectors) {
907
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/upsert`, {
908
+ method: "POST",
909
+ body: JSON.stringify({ vectors })
910
+ });
911
+ }
912
+ /** Similarity search in a collection */
913
+ async queryVectors(collection, options) {
914
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/query`, {
915
+ method: "POST",
916
+ body: JSON.stringify(options)
917
+ });
918
+ }
919
+ /** RAG query — embed text + search collection in one call */
920
+ async ragQuery(options) {
921
+ return this._request(`${this.baseURL}/rag`, {
922
+ method: "POST",
923
+ body: JSON.stringify(options)
924
+ });
523
925
  }
524
926
  /** Search models by name, provider, or capability */
525
927
  async searchModels(query) {
@@ -538,7 +940,68 @@ var TCloudClient = class {
538
940
  const outputCost = options.outputTokens * parseFloat(model.pricing.completion);
539
941
  return { inputCost, outputCost, total: inputCost + outputCost };
540
942
  }
943
+ /**
944
+ * Get a pricing spectrum across resource tiers for a model.
945
+ *
946
+ * Uses REAL per-operator pricing from `operator.models[].inputPrice`.
947
+ * Each tier filters operators by GPU count and TEE capability, then
948
+ * reports the cheapest and most expensive operator for that config.
949
+ *
950
+ * @param options.model - Model ID to price (falls back to client default)
951
+ * @param options.tiers - Number of tiers (1-7, default 5)
952
+ */
953
+ async pricingSpectrum(options) {
954
+ const requestedTiers = Math.max(1, Math.min(options.tiers ?? 5, ALL_TIERS.length));
955
+ const modelId = options.model || this.model;
956
+ const selected = selectTiers(ALL_TIERS, requestedTiers);
957
+ const operatorData = await this.operators();
958
+ const allOperators = operatorData.operators || [];
959
+ return selected.map((tier) => {
960
+ const matching = allOperators.filter((op) => {
961
+ if (tier.gpu > 0 && (op.gpuCount ?? 0) < tier.gpu) return false;
962
+ if (tier.tee && !op.teeAttested) return false;
963
+ return true;
964
+ });
965
+ const prices = matching.map((op) => op.models.find((m) => m.modelId === modelId)?.inputPrice).filter((p) => p != null && p > 0).sort((a, b) => a - b);
966
+ const cheapestPrice = prices[0];
967
+ const priciestPrice = prices.length > 1 ? prices[prices.length - 1] : void 0;
968
+ return {
969
+ tier: tier.name,
970
+ config: tier,
971
+ cheapestPrice,
972
+ priciestPrice: priciestPrice !== cheapestPrice ? priciestPrice : void 0,
973
+ cheapest: cheapestPrice != null ? formatPrice(cheapestPrice) : "no operators for this config",
974
+ priciest: priciestPrice != null && priciestPrice !== cheapestPrice ? formatPrice(priciestPrice) : void 0,
975
+ availableOperators: matching.length,
976
+ operatorsWithModel: prices.length
977
+ };
978
+ });
979
+ }
541
980
  };
981
+ var ALL_TIERS = [
982
+ { name: "cpu-only", cpu: 4, ramGb: 16, gpu: 0, tee: false },
983
+ { name: "gpu", cpu: 8, ramGb: 32, gpu: 1, tee: false },
984
+ { name: "gpu-tee", cpu: 8, ramGb: 32, gpu: 1, tee: true },
985
+ { name: "multi-gpu", cpu: 32, ramGb: 128, gpu: 2, tee: false },
986
+ { name: "multi-gpu-tee", cpu: 32, ramGb: 128, gpu: 2, tee: true },
987
+ { name: "max-gpu", cpu: 64, ramGb: 256, gpu: 4, tee: false },
988
+ { name: "max-gpu-tee", cpu: 64, ramGb: 256, gpu: 4, tee: true }
989
+ ];
990
+ function selectTiers(all, n) {
991
+ if (n >= all.length) return [...all];
992
+ if (n <= 1) return [all[0]];
993
+ if (n === 2) return [all[0], all[all.length - 1]];
994
+ const result = [all[0]];
995
+ const step = (all.length - 1) / (n - 1);
996
+ for (let i = 1; i < n - 1; i++) {
997
+ result.push(all[Math.round(i * step)]);
998
+ }
999
+ result.push(all[all.length - 1]);
1000
+ return result;
1001
+ }
1002
+ function formatPrice(pricePerToken) {
1003
+ return `$${(pricePerToken * 1e3).toFixed(6)}/1K tokens`;
1004
+ }
542
1005
  var TCloudError = class extends Error {
543
1006
  constructor(status, message) {
544
1007
  super(message);