@tangle-network/tcloud 0.1.3 → 0.2.0

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