@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.
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,25 +279,44 @@ 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 DEFAULT_PLATFORM_URL = "https://id.tangle.tools";
291
+ var TCloudClient = class _TCloudClient {
70
292
  baseURL;
293
+ platformURL;
71
294
  apiKey;
72
295
  model;
73
296
  headers;
74
297
  spendAuthFn;
75
298
  privacy;
76
299
  limits;
300
+ retryConfig;
301
+ timeoutMs;
77
302
  _totalSpent = 0;
78
303
  _requestCount = 0;
304
+ privateRouter;
305
+ _cachedOperators = [];
306
+ _operatorsCachedAt = 0;
307
+ static OPERATORS_TTL_MS = 5 * 60 * 1e3;
79
308
  constructor(config = {}) {
80
309
  this.baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/$/, "");
81
- this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY || process.env.OPENAI_API_KEY;
310
+ this.platformURL = (config.platformURL || DEFAULT_PLATFORM_URL).replace(/\/$/, "");
311
+ this.apiKey = config.apiKey || process.env.TCLOUD_API_KEY;
82
312
  this.model = config.model || "gpt-4o-mini";
83
313
  this.privacy = config.privacy;
84
314
  this.limits = config.limits;
315
+ this.retryConfig = config.retry === false ? null : { ...DEFAULT_RETRY, ...config.retry };
316
+ this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
85
317
  this.headers = {
86
318
  "Content-Type": "application/json",
87
- "X-Tangle-Client": "tcloud-sdk/0.1.4"
319
+ "X-Tangle-Client": "tcloud-sdk/0.2.0"
88
320
  };
89
321
  if (this.apiKey) {
90
322
  this.headers["Authorization"] = `Bearer ${this.apiKey}`;
@@ -104,6 +336,17 @@ var TCloudClient = class {
104
336
  if (config.routing?.region) {
105
337
  this.headers["X-Tangle-Region"] = config.routing.region;
106
338
  }
339
+ if (config.routing?.strategy) {
340
+ const strategyMap = {
341
+ "round-robin": "round-robin",
342
+ "lowest-latency": "latency-aware",
343
+ "lowest-price": "round-robin",
344
+ "highest-reputation": "round-robin"
345
+ };
346
+ this.privateRouter = new PrivateRouter({
347
+ strategy: strategyMap[config.routing.strategy] || "round-robin"
348
+ });
349
+ }
107
350
  }
108
351
  /** Set the SpendAuth signer for private mode */
109
352
  setSpendAuthSigner(fn) {
@@ -137,6 +380,25 @@ var TCloudClient = class {
137
380
  if (pct >= 0.8) this.limits.onLimitWarning({ type: "total", current: this._totalSpent, limit: this.limits.maxTotalSpend });
138
381
  }
139
382
  }
383
+ /** Ensure the private router has operators loaded (with TTL-based caching) */
384
+ async ensureRouterOperators() {
385
+ if (!this.privateRouter) return;
386
+ const now = Date.now();
387
+ if (this._cachedOperators.length > 0 && now - this._operatorsCachedAt < _TCloudClient.OPERATORS_TTL_MS) {
388
+ return;
389
+ }
390
+ const data = await this.operators();
391
+ this._cachedOperators = (data.operators || []).map((op) => ({
392
+ slug: op.slug,
393
+ endpointUrl: op.endpointUrl,
394
+ region: "",
395
+ reputationScore: op.reputationScore,
396
+ avgLatencyMs: op.avgLatencyMs,
397
+ models: op.models.map((m) => m.modelId)
398
+ }));
399
+ this._operatorsCachedAt = now;
400
+ this.privateRouter.setOperators(this._cachedOperators);
401
+ }
140
402
  /** Track cost after a response, using actual pricing from response headers when available */
141
403
  trackCost(completion, res) {
142
404
  this._requestCount++;
@@ -156,36 +418,154 @@ var TCloudClient = class {
156
418
  }
157
419
  }
158
420
  }
159
- /** Chat completion (non-streaming) */
160
- async chat(options) {
421
+ /**
422
+ * Core fetch with retry + timeout. All helpers build on this.
423
+ * Retries on retryable status codes with exponential backoff + jitter.
424
+ */
425
+ async _doFetch(url, init, streaming) {
426
+ const retry = this.retryConfig;
427
+ const maxAttempts = retry ? retry.maxRetries + 1 : 1;
428
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
429
+ const controller = new AbortController();
430
+ let timer;
431
+ if (this.timeoutMs > 0 && !streaming) {
432
+ timer = setTimeout(() => controller.abort(), this.timeoutMs);
433
+ }
434
+ try {
435
+ const res = await proxiedFetch(this.privacy, url, {
436
+ ...init,
437
+ signal: controller.signal
438
+ }, streaming);
439
+ if (res.ok) return res;
440
+ if (retry && attempt < retry.maxRetries && retry.retryableStatuses.includes(res.status)) {
441
+ const backoff = Math.min(
442
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
443
+ retry.maxBackoffMs
444
+ );
445
+ const jitter = backoff * 0.5 * Math.random();
446
+ await new Promise((r) => setTimeout(r, backoff + jitter));
447
+ continue;
448
+ }
449
+ const err = await res.json().catch(() => ({ error: res.statusText }));
450
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
451
+ } catch (e) {
452
+ if (e instanceof TCloudError) throw e;
453
+ if (retry && attempt < retry.maxRetries) {
454
+ const backoff = Math.min(
455
+ retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
456
+ retry.maxBackoffMs
457
+ );
458
+ await new Promise((r) => setTimeout(r, backoff));
459
+ continue;
460
+ }
461
+ if (e?.name === "AbortError") {
462
+ throw new TCloudError(408, `Request timed out after ${this.timeoutMs}ms`);
463
+ }
464
+ throw new TCloudError(0, e?.message || "Network error");
465
+ } finally {
466
+ if (timer !== void 0) clearTimeout(timer);
467
+ }
468
+ }
469
+ throw new TCloudError(0, "Retry loop exhausted");
470
+ }
471
+ /**
472
+ * Shared request helper for billable JSON API calls.
473
+ * Enforces: checkLimits → fetch with retry/timeout → error parsing → requestCount.
474
+ */
475
+ async _request(url, init = {}) {
476
+ this.checkLimits();
477
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
478
+ this._requestCount++;
479
+ return res.json();
480
+ }
481
+ /**
482
+ * Shared request helper for read-only/non-billable JSON API calls.
483
+ * No limits check, no request counting.
484
+ */
485
+ async _fetch(url, init = {}) {
486
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
487
+ return res.json();
488
+ }
489
+ /**
490
+ * Shared request helper for billable calls that return non-JSON (e.g. ArrayBuffer).
491
+ */
492
+ async _requestRaw(url, init = {}) {
161
493
  this.checkLimits();
494
+ const res = await this._doFetch(url, { headers: this.headers, ...init }, false);
495
+ this._requestCount++;
496
+ return res;
497
+ }
498
+ /**
499
+ * Prepare headers for chat requests — operator routing + SpendAuth +
500
+ * bridge short-circuit headers when `options.bridge` is set.
501
+ * Shared between chat() and chatStream() to eliminate duplication.
502
+ */
503
+ async _prepareChatRequest(model, bridge) {
162
504
  const headers = { ...this.headers };
163
505
  if (this.spendAuthFn) {
164
506
  const auth2 = await this.spendAuthFn();
165
507
  headers["X-Payment-Signature"] = JSON.stringify(auth2);
166
508
  delete headers["Authorization"];
167
509
  }
168
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/chat/completions`, {
510
+ let baseURL = this.baseURL;
511
+ if (this.privateRouter) {
512
+ await this.ensureRouterOperators();
513
+ const operator = this.privateRouter.selectOperator(model);
514
+ if (operator) {
515
+ baseURL = operator.endpointUrl.replace(/\/$/, "");
516
+ headers["X-Tangle-Operator"] = operator.slug;
517
+ delete headers["Authorization"];
518
+ }
519
+ }
520
+ if (bridge) {
521
+ headers["X-Bridge-Unlock"] = bridge.unlock;
522
+ if (bridge.resume) headers["X-Resume"] = bridge.resume;
523
+ if (bridge.bridgeUrl) headers["X-Bridge-Url"] = bridge.bridgeUrl;
524
+ if (bridge.bridgeBearer) headers["X-Bridge-Bearer"] = bridge.bridgeBearer;
525
+ }
526
+ return { headers, baseURL };
527
+ }
528
+ /**
529
+ * Resolve the effective model string. When a bridge is set, rewrite to
530
+ * `bridge/<harness>/<model>` (or `bridge/<harness>` if no model).
531
+ */
532
+ _effectiveModel(options) {
533
+ if (options.bridge) {
534
+ return options.bridge.model ? `bridge/${options.bridge.harness}/${options.bridge.model}` : `bridge/${options.bridge.harness}`;
535
+ }
536
+ return options.model || this.model;
537
+ }
538
+ /** Build the chat completions request body */
539
+ _chatBody(options, stream) {
540
+ return JSON.stringify({
541
+ model: this._effectiveModel(options),
542
+ messages: options.messages,
543
+ temperature: options.temperature,
544
+ max_tokens: options.maxTokens,
545
+ stream,
546
+ stop: options.stop,
547
+ top_p: options.topP,
548
+ frequency_penalty: options.frequencyPenalty,
549
+ presence_penalty: options.presencePenalty,
550
+ response_format: options.responseFormat,
551
+ tools: options.tools,
552
+ tool_choice: options.toolChoice,
553
+ ...options.gateway ? { gateway: options.gateway } : {},
554
+ ...options.providerOptions
555
+ });
556
+ }
557
+ /** Chat completion (non-streaming) */
558
+ async chat(options) {
559
+ this.checkLimits();
560
+ const { headers, baseURL } = await this._prepareChatRequest(
561
+ this._effectiveModel(options),
562
+ options.bridge
563
+ );
564
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
169
565
  method: "POST",
170
566
  headers,
171
- body: JSON.stringify({
172
- model: options.model || this.model,
173
- messages: options.messages,
174
- temperature: options.temperature,
175
- max_tokens: options.maxTokens,
176
- stream: false,
177
- stop: options.stop,
178
- top_p: options.topP,
179
- frequency_penalty: options.frequencyPenalty,
180
- presence_penalty: options.presencePenalty,
181
- response_format: options.responseFormat,
182
- tools: options.tools
183
- })
567
+ body: this._chatBody(options, false)
184
568
  }, false);
185
- if (!res.ok) {
186
- const err = await res.json().catch(() => ({ error: res.statusText }));
187
- throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
188
- }
189
569
  const completion = await res.json();
190
570
  this.trackCost(completion, res);
191
571
  return completion;
@@ -193,29 +573,16 @@ var TCloudClient = class {
193
573
  /** Chat completion (streaming) — returns an async iterator of chunks */
194
574
  async *chatStream(options) {
195
575
  this.checkLimits();
196
- const headers = { ...this.headers };
197
- if (this.spendAuthFn) {
198
- const auth2 = await this.spendAuthFn();
199
- headers["X-Payment-Signature"] = JSON.stringify(auth2);
200
- delete headers["Authorization"];
201
- }
202
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/chat/completions`, {
576
+ this._requestCount++;
577
+ const { headers, baseURL } = await this._prepareChatRequest(
578
+ this._effectiveModel(options),
579
+ options.bridge
580
+ );
581
+ const res = await this._doFetch(`${baseURL}/chat/completions`, {
203
582
  method: "POST",
204
583
  headers,
205
- body: JSON.stringify({
206
- model: options.model || this.model,
207
- messages: options.messages,
208
- temperature: options.temperature,
209
- max_tokens: options.maxTokens,
210
- stream: true,
211
- stop: options.stop,
212
- top_p: options.topP
213
- })
584
+ body: this._chatBody(options, true)
214
585
  }, true);
215
- if (!res.ok) {
216
- const err = await res.json().catch(() => ({ error: res.statusText }));
217
- throw new TCloudError(res.status, err.error || err.message || res.statusText);
218
- }
219
586
  const reader = res.body.getReader();
220
587
  const decoder = new TextDecoder();
221
588
  let buf = "";
@@ -223,13 +590,13 @@ var TCloudClient = class {
223
590
  const { done, value } = await reader.read();
224
591
  if (done) break;
225
592
  buf += decoder.decode(value, { stream: true });
593
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
226
594
  const lines = buf.split("\n");
227
595
  buf = lines.pop() || "";
228
596
  for (const line of lines) {
229
597
  if (!line.startsWith("data: ")) continue;
230
598
  const data = line.slice(6).trim();
231
599
  if (data === "[DONE]") {
232
- this._requestCount++;
233
600
  return;
234
601
  }
235
602
  try {
@@ -239,6 +606,24 @@ var TCloudClient = class {
239
606
  }
240
607
  }
241
608
  }
609
+ /**
610
+ * Bridge — scoped helper for a subscription-backed CLI harness behind
611
+ * the Tangle Router's cli-bridge. Returns a mini-client bound to
612
+ * (harness, unlock, resume) so you don't thread those through every
613
+ * call.
614
+ *
615
+ * ```ts
616
+ * const kimi = tcloud.bridge({ harness: 'kimi', model: 'kimi-for-coding', unlock: UNLOCK, resume: 'pr-42' })
617
+ * await kimi.ask('review this diff…')
618
+ * for await (const chunk of kimi.stream('continue…')) process.stdout.write(chunk)
619
+ * ```
620
+ *
621
+ * Sessions persist across process restarts — use the same `resume` id
622
+ * to land on the same CLI conversation (context intact, no replay tax).
623
+ */
624
+ bridge(cfg) {
625
+ return new BridgeSession(this, cfg);
626
+ }
242
627
  /** Convenience: send a single message and get the text response */
243
628
  async ask(message, modelOrOptions) {
244
629
  const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
@@ -269,85 +654,112 @@ var TCloudClient = class {
269
654
  }
270
655
  /** List available models */
271
656
  async models() {
272
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/models`, { headers: this.headers }, false);
273
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch models");
274
- const data = await res.json();
657
+ const data = await this._fetch(`${this.baseURL}/models`);
275
658
  return data.data || [];
276
659
  }
277
660
  /** List active operators */
278
661
  async operators() {
279
662
  const apiRoot = this.baseURL.replace(/\/v1$/, "");
280
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/operators`, { headers: this.headers }, false);
281
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch operators");
282
- return res.json();
663
+ return this._fetch(`${apiRoot}/api/operators`);
283
664
  }
665
+ // ── Billing (via id.tangle.tools) ──
284
666
  /** Get credit balance */
285
667
  async credits() {
286
- const apiRoot = this.baseURL.replace(/\/v1$/, "");
287
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/billing`, { headers: this.headers }, false);
288
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch credits");
289
- return res.json();
668
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/balance`);
669
+ return data;
290
670
  }
291
- /** Add credits */
671
+ /** Add credits via Stripe checkout. Returns the checkout URL. */
292
672
  async addCredits(amount) {
293
- const apiRoot = this.baseURL.replace(/\/v1$/, "");
294
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/billing`, {
673
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/topup`, {
295
674
  method: "POST",
296
- headers: this.headers,
297
675
  body: JSON.stringify({ amount })
298
- }, false);
299
- if (!res.ok) throw new TCloudError(res.status, "Failed to add credits");
300
- return res.json();
676
+ });
677
+ return data;
301
678
  }
302
- /** Create a new API key */
303
- async createKey(name) {
304
- const apiRoot = this.baseURL.replace(/\/v1$/, "");
305
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys`, {
679
+ /** Get transaction history */
680
+ async transactions(limit = 50) {
681
+ const { data } = await this._fetch(`${this.platformURL}/v1/billing/transactions?limit=${limit}`);
682
+ return data;
683
+ }
684
+ // ── API Keys (via id.tangle.tools) ──
685
+ /**
686
+ * Create a new API key.
687
+ * When called with an API key (not session), the new key is automatically
688
+ * a child of the calling key — enabling hierarchical key delegation.
689
+ *
690
+ * Pass `parentKeyId` explicitly to create a child of a specific key.
691
+ * Child keys inherit the parent's product scope, allowedModels, and rpmLimit
692
+ * if not specified. Budget cannot exceed the parent's remaining budget.
693
+ */
694
+ async createKey(opts) {
695
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys`, {
306
696
  method: "POST",
307
- headers: this.headers,
308
- body: JSON.stringify({ name })
309
- }, false);
310
- if (!res.ok) throw new TCloudError(res.status, "Failed to create API key");
311
- return res.json();
697
+ body: JSON.stringify(opts)
698
+ });
699
+ return data;
312
700
  }
313
- /** List API keys */
314
- async keys() {
315
- const apiRoot = this.baseURL.replace(/\/v1$/, "");
316
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys`, { headers: this.headers }, false);
317
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch keys");
318
- return res.json();
701
+ /** Get a single API key by ID */
702
+ async getKey(id) {
703
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`);
704
+ return data;
705
+ }
706
+ /**
707
+ * List API keys.
708
+ * Pass `children: true` to list child keys of the calling API key.
709
+ */
710
+ async keys(opts) {
711
+ const q = opts?.children ? "?children=true" : "";
712
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys${q}`);
713
+ return data;
714
+ }
715
+ /**
716
+ * Update an API key's limits.
717
+ * Can adjust budget, allowedModels, rpmLimit, expiresAt, and name.
718
+ */
719
+ async updateKey(id, updates) {
720
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`, {
721
+ method: "PATCH",
722
+ body: JSON.stringify(updates)
723
+ });
724
+ return data;
319
725
  }
320
- /** Revoke an API key */
726
+ /** Revoke an API key. If the key has children, they are also revoked recursively. */
321
727
  async revokeKey(id) {
322
- const apiRoot = this.baseURL.replace(/\/v1$/, "");
323
- const res = await proxiedFetch(this.privacy, `${apiRoot}/api/keys/${id}`, {
324
- method: "DELETE",
325
- headers: this.headers
326
- }, false);
327
- if (!res.ok) throw new TCloudError(res.status, "Failed to revoke key");
728
+ await this._fetch(`${this.platformURL}/v1/keys/${id}`, { method: "DELETE" });
729
+ }
730
+ /** Rotate an API key — creates new key with same config, revokes old */
731
+ async rotateKey(id) {
732
+ const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}/rotate`, { method: "POST" });
733
+ return data;
734
+ }
735
+ // ── Projects (via id.tangle.tools) ──
736
+ /** Create a project for usage attribution */
737
+ async createProject(name, product) {
738
+ const { data } = await this._fetch(`${this.platformURL}/v1/projects`, {
739
+ method: "POST",
740
+ body: JSON.stringify({ name, product })
741
+ });
742
+ return data;
743
+ }
744
+ /** List projects */
745
+ async projects() {
746
+ const { data } = await this._fetch(`${this.platformURL}/v1/projects`);
747
+ return data;
328
748
  }
329
749
  /** Generate embeddings */
330
750
  async embeddings(options) {
331
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/embeddings`, {
751
+ return this._request(`${this.baseURL}/embeddings`, {
332
752
  method: "POST",
333
- headers: this.headers,
334
753
  body: JSON.stringify({
335
754
  model: options.model || "text-embedding-3-small",
336
755
  input: options.input
337
756
  })
338
- }, false);
339
- if (!res.ok) {
340
- const err = await res.json().catch(() => ({ error: res.statusText }));
341
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
342
- }
343
- this._requestCount++;
344
- return res.json();
757
+ });
345
758
  }
346
759
  /** Generate images */
347
760
  async imageGenerate(options) {
348
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/images/generations`, {
761
+ return this._request(`${this.baseURL}/images/generations`, {
349
762
  method: "POST",
350
- headers: this.headers,
351
763
  body: JSON.stringify({
352
764
  model: options.model || "dall-e-3",
353
765
  prompt: options.prompt,
@@ -356,56 +768,36 @@ var TCloudClient = class {
356
768
  quality: options.quality,
357
769
  response_format: options.response_format
358
770
  })
359
- }, false);
360
- if (!res.ok) {
361
- const err = await res.json().catch(() => ({ error: res.statusText }));
362
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
363
- }
364
- this._requestCount++;
365
- return res.json();
771
+ });
366
772
  }
367
773
  /** Rerank documents by relevance to a query */
368
774
  async rerank(options) {
369
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/rerank`, {
775
+ return this._request(`${this.baseURL}/rerank`, {
370
776
  method: "POST",
371
- headers: this.headers,
372
777
  body: JSON.stringify({
373
778
  model: options.model || "rerank-english-v3.0",
374
779
  query: options.query,
375
780
  documents: options.documents,
376
781
  top_n: options.top_n
377
782
  })
378
- }, false);
379
- if (!res.ok) {
380
- const err = await res.json().catch(() => ({ error: res.statusText }));
381
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
382
- }
383
- this._requestCount++;
384
- return res.json();
783
+ });
385
784
  }
386
785
  /** Text-to-speech */
387
786
  async speech(options) {
388
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/speech`, {
787
+ const res = await this._requestRaw(`${this.baseURL}/audio/speech`, {
389
788
  method: "POST",
390
- headers: this.headers,
391
789
  body: JSON.stringify({
392
790
  model: options.model || "tts-1",
393
791
  input: options.input,
394
792
  voice: options.voice || "alloy"
395
793
  })
396
- }, false);
397
- if (!res.ok) {
398
- const err = await res.json().catch(() => ({ error: res.statusText }));
399
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
400
- }
401
- this._requestCount++;
794
+ });
402
795
  return res.arrayBuffer();
403
796
  }
404
797
  /** Legacy completions endpoint */
405
798
  async completions(options) {
406
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/completions`, {
799
+ return this._request(`${this.baseURL}/completions`, {
407
800
  method: "POST",
408
- headers: this.headers,
409
801
  body: JSON.stringify({
410
802
  model: options.model || this.model,
411
803
  prompt: options.prompt,
@@ -414,13 +806,7 @@ var TCloudClient = class {
414
806
  stop: options.stop,
415
807
  top_p: options.topP
416
808
  })
417
- }, false);
418
- if (!res.ok) {
419
- const err = await res.json().catch(() => ({ error: res.statusText }));
420
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
421
- }
422
- this._requestCount++;
423
- return res.json();
809
+ });
424
810
  }
425
811
  /** Audio transcription (speech-to-text) */
426
812
  async transcribe(file, options) {
@@ -431,6 +817,7 @@ var TCloudClient = class {
431
817
  if (options?.prompt) formData.append("prompt", options.prompt);
432
818
  const headers = { ...this.headers };
433
819
  delete headers["Content-Type"];
820
+ this.checkLimits();
434
821
  const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/transcriptions`, {
435
822
  method: "POST",
436
823
  headers,
@@ -438,75 +825,181 @@ var TCloudClient = class {
438
825
  }, false);
439
826
  if (!res.ok) {
440
827
  const err = await res.json().catch(() => ({ error: res.statusText }));
441
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
828
+ throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
442
829
  }
443
830
  this._requestCount++;
444
831
  return res.json();
445
832
  }
446
833
  /** Create a fine-tuning job */
447
834
  async fineTuneCreate(options) {
448
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/fine_tuning/jobs`, {
835
+ return this._request(`${this.baseURL}/fine_tuning/jobs`, {
449
836
  method: "POST",
450
- headers: this.headers,
451
837
  body: JSON.stringify(options)
452
- }, false);
453
- if (!res.ok) {
454
- const err = await res.json().catch(() => ({ error: res.statusText }));
455
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
456
- }
457
- this._requestCount++;
458
- return res.json();
838
+ });
459
839
  }
460
840
  /** List fine-tuning jobs */
461
841
  async fineTuneList() {
462
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/fine_tuning/jobs`, {
463
- headers: this.headers
464
- }, false);
465
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch fine-tuning jobs");
466
- return res.json();
842
+ return this._fetch(`${this.baseURL}/fine_tuning/jobs`);
467
843
  }
468
844
  /** Submit a batch of chat requests */
469
845
  async batch(requests) {
470
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/batch`, {
846
+ return this._request(`${this.baseURL}/batch`, {
471
847
  method: "POST",
472
- headers: this.headers,
473
848
  body: JSON.stringify({ requests })
474
- }, false);
475
- if (!res.ok) {
476
- const err = await res.json().catch(() => ({ error: res.statusText }));
477
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
478
- }
479
- return res.json();
849
+ });
480
850
  }
481
851
  /** Get batch job status */
482
852
  async batchStatus(jobId) {
483
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/batch?id=${jobId}`, {
484
- headers: this.headers
485
- }, false);
486
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch batch status");
487
- return res.json();
853
+ return this._fetch(`${this.baseURL}/batch?id=${jobId}`);
488
854
  }
489
855
  /** Generate video */
490
856
  async videoGenerate(options) {
491
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/video/generate`, {
857
+ return this._request(`${this.baseURL}/video/generate`, {
492
858
  method: "POST",
493
- headers: this.headers,
494
859
  body: JSON.stringify(options)
495
- }, false);
496
- if (!res.ok) {
497
- const err = await res.json().catch(() => ({ error: res.statusText }));
498
- throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
499
- }
500
- this._requestCount++;
501
- return res.json();
860
+ });
502
861
  }
503
862
  /** Get video generation status */
504
863
  async videoStatus(id) {
505
- const res = await proxiedFetch(this.privacy, `${this.baseURL}/video?id=${id}`, {
506
- headers: this.headers
507
- }, false);
508
- if (!res.ok) throw new TCloudError(res.status, "Failed to fetch video status");
509
- return res.json();
864
+ return this._fetch(`${this.baseURL}/video?id=${id}`);
865
+ }
866
+ /** Generate an avatar video (lip-synced talking head from audio + face image).
867
+ * Returns 202 with a job_id for async polling via avatarJobStatus(). */
868
+ async avatarGenerate(options) {
869
+ return this._request(`${this.baseURL}/avatar/generate`, {
870
+ method: "POST",
871
+ body: JSON.stringify(options)
872
+ });
873
+ }
874
+ /** Poll an avatar generation job by ID. */
875
+ async avatarJobStatus(jobId) {
876
+ return this._fetch(`${this.baseURL}/avatar/jobs/${jobId}`);
877
+ }
878
+ /** Poll an avatar job until it reaches a terminal state (completed/failed).
879
+ * Returns the final job status. Throws on failure. */
880
+ async pollAvatarJob(jobId, options) {
881
+ const interval = options?.intervalMs ?? 5e3;
882
+ const timeout = options?.timeoutMs ?? 3e5;
883
+ const deadline = Date.now() + timeout;
884
+ while (Date.now() < deadline) {
885
+ const job = await this.avatarJobStatus(jobId);
886
+ if (job.status === "completed") return job;
887
+ if (job.status === "failed") {
888
+ throw new TCloudError(500, job.error || `Avatar job ${jobId} failed`);
889
+ }
890
+ await new Promise((r) => setTimeout(r, interval));
891
+ }
892
+ throw new TCloudError(408, `Avatar job ${jobId} timed out after ${timeout}ms`);
893
+ }
894
+ /**
895
+ * Watch an async job via SSE until it reaches a terminal state.
896
+ * Works with avatar, video, and training blueprint operators.
897
+ *
898
+ * @param jobId - The job ID returned by the creation endpoint
899
+ * @param options - Optional: operatorUrl override, onEvent callback
900
+ * @returns The final JobEvent (completed/failed/cancelled)
901
+ */
902
+ async watchJob(jobId, options) {
903
+ const base = options?.operatorUrl?.replace(/\/$/, "") || this.baseURL;
904
+ const url = `${base}/v1/jobs/${encodeURIComponent(jobId)}/events`;
905
+ const timeout = options?.timeout ?? 3e5;
906
+ const controller = new AbortController();
907
+ const timer = setTimeout(() => controller.abort(), timeout);
908
+ try {
909
+ const watchHeaders = {
910
+ ...this.headers,
911
+ Accept: "text/event-stream"
912
+ };
913
+ if (options?.operatorUrl) {
914
+ delete watchHeaders["Authorization"];
915
+ }
916
+ if (options?.sseToken) {
917
+ watchHeaders["Authorization"] = `Bearer ${options.sseToken}`;
918
+ }
919
+ const res = await proxiedFetch(this.privacy, url, {
920
+ headers: watchHeaders,
921
+ signal: controller.signal
922
+ }, true);
923
+ if (!res.ok) {
924
+ const err = await res.json().catch(() => ({ error: res.statusText }));
925
+ throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
926
+ }
927
+ const reader = res.body.getReader();
928
+ const decoder = new TextDecoder();
929
+ let buf = "";
930
+ const terminalStatuses = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
931
+ while (true) {
932
+ const { done, value } = await reader.read();
933
+ if (done) {
934
+ throw new TCloudError(502, `SSE stream ended without terminal event for job ${jobId}`);
935
+ }
936
+ buf += decoder.decode(value, { stream: true });
937
+ if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow \u2014 server sent >1MB without newline");
938
+ const lines = buf.split("\n");
939
+ buf = lines.pop() || "";
940
+ for (const line of lines) {
941
+ if (!line.startsWith("data: ")) continue;
942
+ const data = line.slice(6).trim();
943
+ if (!data || data === "[DONE]") continue;
944
+ let event;
945
+ try {
946
+ event = JSON.parse(data);
947
+ } catch {
948
+ continue;
949
+ }
950
+ try {
951
+ options?.onEvent?.(event);
952
+ } catch (cbErr) {
953
+ console.error("watchJob onEvent callback error:", cbErr);
954
+ }
955
+ if (terminalStatuses.has(event.status)) {
956
+ return event;
957
+ }
958
+ }
959
+ }
960
+ } catch (err) {
961
+ if (err?.name === "AbortError") {
962
+ throw new TCloudError(408, `Job ${jobId} timed out after ${timeout}ms`);
963
+ }
964
+ throw err;
965
+ } finally {
966
+ clearTimeout(timer);
967
+ }
968
+ }
969
+ // ---------------------------------------------------------------------------
970
+ // Vector Store (requires operator routing — X-Tangle-Service/Blueprint/Operator)
971
+ // ---------------------------------------------------------------------------
972
+ /** Create a vector collection on the operator's vector store */
973
+ async createCollection(options) {
974
+ return this._request(`${this.baseURL}/collections`, {
975
+ method: "POST",
976
+ body: JSON.stringify(options)
977
+ });
978
+ }
979
+ /** List collections on the operator's vector store */
980
+ async listCollections() {
981
+ return this._fetch(`${this.baseURL}/collections`);
982
+ }
983
+ /** Upsert vectors into a collection */
984
+ async upsertVectors(collection, vectors) {
985
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/upsert`, {
986
+ method: "POST",
987
+ body: JSON.stringify({ vectors })
988
+ });
989
+ }
990
+ /** Similarity search in a collection */
991
+ async queryVectors(collection, options) {
992
+ return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/query`, {
993
+ method: "POST",
994
+ body: JSON.stringify(options)
995
+ });
996
+ }
997
+ /** RAG query — embed text + search collection in one call */
998
+ async ragQuery(options) {
999
+ return this._request(`${this.baseURL}/rag`, {
1000
+ method: "POST",
1001
+ body: JSON.stringify(options)
1002
+ });
510
1003
  }
511
1004
  /** Search models by name, provider, or capability */
512
1005
  async searchModels(query) {
@@ -525,7 +1018,176 @@ var TCloudClient = class {
525
1018
  const outputCost = options.outputTokens * parseFloat(model.pricing.completion);
526
1019
  return { inputCost, outputCost, total: inputCost + outputCost };
527
1020
  }
1021
+ /**
1022
+ * Get a pricing spectrum across resource tiers for a model.
1023
+ *
1024
+ * Uses REAL per-operator pricing from `operator.models[].inputPrice`.
1025
+ * Each tier filters operators by GPU count and TEE capability, then
1026
+ * reports the cheapest and most expensive operator for that config.
1027
+ *
1028
+ * @param options.model - Model ID to price (falls back to client default)
1029
+ * @param options.tiers - Number of tiers (1-7, default 5)
1030
+ */
1031
+ async pricingSpectrum(options) {
1032
+ const requestedTiers = Math.max(1, Math.min(options.tiers ?? 5, ALL_TIERS.length));
1033
+ const modelId = options.model || this.model;
1034
+ const selected = selectTiers(ALL_TIERS, requestedTiers);
1035
+ const operatorData = await this.operators();
1036
+ const allOperators = operatorData.operators || [];
1037
+ return selected.map((tier) => {
1038
+ const matching = allOperators.filter((op) => {
1039
+ if (tier.gpu > 0 && (op.gpuCount ?? 0) < tier.gpu) return false;
1040
+ if (tier.tee && !op.teeAttested) return false;
1041
+ return true;
1042
+ });
1043
+ const prices = matching.map((op) => op.models.find((m) => m.modelId === modelId)?.inputPrice).filter((p) => p != null && p > 0).sort((a, b) => a - b);
1044
+ const cheapestPrice = prices[0];
1045
+ const priciestPrice = prices.length > 1 ? prices[prices.length - 1] : void 0;
1046
+ return {
1047
+ tier: tier.name,
1048
+ config: tier,
1049
+ cheapestPrice,
1050
+ priciestPrice: priciestPrice !== cheapestPrice ? priciestPrice : void 0,
1051
+ cheapest: cheapestPrice != null ? formatPrice(cheapestPrice) : "no operators for this config",
1052
+ priciest: priciestPrice != null && priciestPrice !== cheapestPrice ? formatPrice(priciestPrice) : void 0,
1053
+ availableOperators: matching.length,
1054
+ operatorsWithModel: prices.length
1055
+ };
1056
+ });
1057
+ }
1058
+ // ── Eval ──────────────────────────────────────────────────────────────
1059
+ get _apiRoot() {
1060
+ return this.baseURL.replace(/\/v1$/, "");
1061
+ }
1062
+ async eval(opts) {
1063
+ return this._request(`${this._apiRoot}/api/eval`, { method: "POST", body: JSON.stringify(opts) });
1064
+ }
1065
+ async createSuite(opts) {
1066
+ return this._request(`${this._apiRoot}/api/eval/suites`, { method: "POST", body: JSON.stringify(opts) });
1067
+ }
1068
+ async listSuites() {
1069
+ return this._fetch(`${this._apiRoot}/api/eval/suites`);
1070
+ }
1071
+ async runSuite(suiteId, opts) {
1072
+ return this._request(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`, { method: "POST", body: JSON.stringify(opts || {}) });
1073
+ }
1074
+ async listRuns(suiteId) {
1075
+ return this._fetch(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`);
1076
+ }
1077
+ async getRun(runId) {
1078
+ return this._fetch(`${this._apiRoot}/api/eval/runs/${runId}`);
1079
+ }
1080
+ async setBaseline(runId) {
1081
+ await this._request(`${this._apiRoot}/api/eval/runs/${runId}`, { method: "PATCH", body: JSON.stringify({ baseline: true }) });
1082
+ }
1083
+ // ── Sandbox ──────────────────────────────────────────────────────────
1084
+ async sandboxPricing(opts) {
1085
+ const p = new URLSearchParams();
1086
+ if (opts?.cpu) p.set("cpu", String(opts.cpu));
1087
+ if (opts?.ram) p.set("ram", String(opts.ram));
1088
+ if (opts?.disk) p.set("disk", String(opts.disk));
1089
+ return this._fetch(`${this._apiRoot}/api/sandbox/pricing?${p}`);
1090
+ }
1091
+ async sandboxStatus() {
1092
+ return this._fetch(`${this._apiRoot}/api/sandbox/link-key`);
1093
+ }
1094
+ async sandboxProvision() {
1095
+ return this._request(`${this._apiRoot}/api/sandbox/provision`, { method: "POST" });
1096
+ }
1097
+ async sandboxCreate(opts) {
1098
+ return this._request(`${this._apiRoot}/api/sandbox/sessions`, { method: "POST", body: JSON.stringify(opts) });
1099
+ }
1100
+ async sandboxList() {
1101
+ return this._fetch(`${this._apiRoot}/api/sandbox/sessions`);
1102
+ }
1103
+ async sandboxStats(sandboxId) {
1104
+ return this._fetch(`${this._apiRoot}/api/sandbox/stats/${sandboxId}`);
1105
+ }
1106
+ async sandboxDestroy(sessionId) {
1107
+ return this._request(`${this._apiRoot}/api/sandbox/sessions/${sessionId}`, { method: "DELETE" });
1108
+ }
1109
+ // ── User Info ────────────────────────────────────────────────────────
1110
+ async userInfo() {
1111
+ return this._fetch(`${this._apiRoot}/api/auth/userinfo`);
1112
+ }
1113
+ };
1114
+ var ALL_TIERS = [
1115
+ { name: "cpu-only", cpu: 4, ramGb: 16, gpu: 0, tee: false },
1116
+ { name: "gpu", cpu: 8, ramGb: 32, gpu: 1, tee: false },
1117
+ { name: "gpu-tee", cpu: 8, ramGb: 32, gpu: 1, tee: true },
1118
+ { name: "multi-gpu", cpu: 32, ramGb: 128, gpu: 2, tee: false },
1119
+ { name: "multi-gpu-tee", cpu: 32, ramGb: 128, gpu: 2, tee: true },
1120
+ { name: "max-gpu", cpu: 64, ramGb: 256, gpu: 4, tee: false },
1121
+ { name: "max-gpu-tee", cpu: 64, ramGb: 256, gpu: 4, tee: true }
1122
+ ];
1123
+ var BridgeSession = class _BridgeSession {
1124
+ constructor(client, cfg) {
1125
+ this.client = client;
1126
+ this.cfg = cfg;
1127
+ }
1128
+ /** Full chat completion (non-streaming). */
1129
+ async chat(options) {
1130
+ return this.client.chat({ ...options, bridge: this.cfg });
1131
+ }
1132
+ /** Stream OpenAI chat.completion.chunks. */
1133
+ chatStream(options) {
1134
+ return this.client.chatStream({ ...options, bridge: this.cfg });
1135
+ }
1136
+ /** One-shot: send a string, get the assistant text. */
1137
+ async ask(message, extra) {
1138
+ const completion = await this.chat({
1139
+ messages: [{ role: "user", content: message }],
1140
+ ...extra
1141
+ });
1142
+ return completion.choices[0]?.message?.content || "";
1143
+ }
1144
+ /** One-shot: send a string, stream text deltas. */
1145
+ async *stream(message, extra) {
1146
+ for await (const chunk of this.chatStream({
1147
+ messages: [{ role: "user", content: message }],
1148
+ ...extra
1149
+ })) {
1150
+ const content = chunk.choices?.[0]?.delta?.content;
1151
+ if (content) yield content;
1152
+ }
1153
+ }
1154
+ /** Turn-based: send full message history, get assistant text. */
1155
+ async turn(messages, extra) {
1156
+ const completion = await this.chat({ messages, ...extra });
1157
+ return completion.choices[0]?.message?.content || "";
1158
+ }
1159
+ /** Clone with a new resume id — same harness, different logical conversation. */
1160
+ withResume(resume) {
1161
+ return new _BridgeSession(this.client, { ...this.cfg, resume });
1162
+ }
1163
+ /** Clone with a different model inside the same harness. */
1164
+ withModel(model) {
1165
+ return new _BridgeSession(this.client, { ...this.cfg, model });
1166
+ }
1167
+ /** The effective model id that will land on the router (`bridge/<harness>/<model>`). */
1168
+ get model() {
1169
+ return this.cfg.model ? `bridge/${this.cfg.harness}/${this.cfg.model}` : `bridge/${this.cfg.harness}`;
1170
+ }
1171
+ /** The resume id currently bound to this session, if any. */
1172
+ get resume() {
1173
+ return this.cfg.resume;
1174
+ }
528
1175
  };
1176
+ function selectTiers(all, n) {
1177
+ if (n >= all.length) return [...all];
1178
+ if (n <= 1) return [all[0]];
1179
+ if (n === 2) return [all[0], all[all.length - 1]];
1180
+ const result = [all[0]];
1181
+ const step = (all.length - 1) / (n - 1);
1182
+ for (let i = 1; i < n - 1; i++) {
1183
+ result.push(all[Math.round(i * step)]);
1184
+ }
1185
+ result.push(all[all.length - 1]);
1186
+ return result;
1187
+ }
1188
+ function formatPrice(pricePerToken) {
1189
+ return `$${(pricePerToken * 1e3).toFixed(6)}/1K tokens`;
1190
+ }
529
1191
  var TCloudError = class extends Error {
530
1192
  constructor(status, message) {
531
1193
  super(message);
@@ -831,6 +1493,7 @@ var TCloud = class _TCloud extends TCloudClient {
831
1493
  var fs = __toESM(require("fs"), 1);
832
1494
  var path = __toESM(require("path"), 1);
833
1495
  var readline = __toESM(require("readline"), 1);
1496
+ var import_child_process = require("child_process");
834
1497
  var CONFIG_DIR = path.join(process.env.HOME || "~", ".tcloud");
835
1498
  var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
836
1499
  var WALLETS_FILE = path.join(CONFIG_DIR, "wallets.json");
@@ -879,23 +1542,52 @@ program.command("config").description("View or update configuration").option("--
879
1542
  console.log(JSON.stringify(c, null, 2));
880
1543
  });
881
1544
  var auth = program.command("auth").description("Authentication");
882
- auth.command("login").description("Log in via browser (device flow)").action(async () => {
1545
+ function openBrowser(url) {
1546
+ if (process.env.NO_BROWSER || process.env.CI) return false;
1547
+ if (!process.stdout.isTTY) return false;
1548
+ try {
1549
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1550
+ const child = (0, import_child_process.spawn)(cmd, [url], { stdio: "ignore", detached: true });
1551
+ child.unref();
1552
+ return true;
1553
+ } catch {
1554
+ return false;
1555
+ }
1556
+ }
1557
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1558
+ async function runDeviceFlow(mode) {
883
1559
  const config = loadConfig();
1560
+ const initRes = await fetch(`${config.apiUrl}/api/auth/device`, { method: "POST" });
1561
+ if (!initRes.ok) {
1562
+ console.error(`
1563
+ \u2717 Auth server returned ${initRes.status}.`);
1564
+ process.exit(1);
1565
+ }
1566
+ const d = await initRes.json();
1567
+ const urlWithCode = `${d.verification_url}?user_code=${encodeURIComponent(d.user_code)}`;
1568
+ const opened = openBrowser(urlWithCode);
1569
+ const header = mode === "signup" ? "Creating your Tangle account" : "Signing you in";
1570
+ console.log(`
1571
+ ${header}
1572
+ `);
1573
+ console.log(` ${opened ? "Browser opened:" : "Open this URL:"}`);
1574
+ console.log(` ${urlWithCode}
1575
+ `);
1576
+ console.log(` Verification code: ${d.user_code}
1577
+ `);
1578
+ const deadline = Date.now() + (d.expires_in || 600) * 1e3;
1579
+ const interval = Math.max(2, d.interval || 5) * 1e3;
1580
+ const spinnerEnabled = process.stdout.isTTY && !process.env.CI;
1581
+ let frame = 0;
1582
+ function tickSpinner() {
1583
+ if (!spinnerEnabled) return;
1584
+ const remaining = Math.max(0, Math.round((deadline - Date.now()) / 1e3));
1585
+ process.stdout.write(`\r ${SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length]} waiting for browser confirmation (${remaining}s remaining) `);
1586
+ }
1587
+ const spinnerTimer = spinnerEnabled ? setInterval(tickSpinner, 100) : null;
884
1588
  try {
885
- const res = await fetch(`${config.apiUrl}/api/auth/device`, { method: "POST" });
886
- if (!res.ok) {
887
- console.error("Auth server error");
888
- process.exit(1);
889
- }
890
- const d = await res.json();
891
- console.log(`
892
- Open: ${d.verification_url}
893
- Code: ${d.user_code}
894
-
895
- Waiting...`);
896
- const deadline = Date.now() + (d.expires_in || 600) * 1e3;
897
1589
  while (Date.now() < deadline) {
898
- await new Promise((r2) => setTimeout(r2, (d.interval || 5) * 1e3));
1590
+ await new Promise((r2) => setTimeout(r2, interval));
899
1591
  const r = await fetch(`${config.apiUrl}/api/auth/device/token`, {
900
1592
  method: "POST",
901
1593
  headers: { "Content-Type": "application/json" },
@@ -905,31 +1597,112 @@ auth.command("login").description("Log in via browser (device flow)").action(asy
905
1597
  if (t.access_token) {
906
1598
  config.apiKey = t.access_token;
907
1599
  saveConfig(config);
908
- console.log("\n Authenticated!");
1600
+ if (spinnerTimer) {
1601
+ clearInterval(spinnerTimer);
1602
+ process.stdout.write("\r" + " ".repeat(80) + "\r");
1603
+ }
1604
+ console.log(` \u2713 Authenticated`);
1605
+ console.log(` \u2713 API key saved to ${CONFIG_FILE}
1606
+ `);
1607
+ console.log(` Try it:`);
1608
+ console.log(` tcloud whoami`);
1609
+ console.log(` tcloud chat "hello world"
1610
+ `);
909
1611
  return;
910
1612
  }
911
1613
  if (t.error === "expired_token") {
912
- console.error("\n Code expired.");
1614
+ if (spinnerTimer) {
1615
+ clearInterval(spinnerTimer);
1616
+ process.stdout.write("\r" + " ".repeat(80) + "\r");
1617
+ }
1618
+ console.error(` \u2717 Code expired. Run 'tcloud auth ${mode}' again.`);
913
1619
  process.exit(1);
914
1620
  }
915
- process.stdout.write(".");
916
1621
  }
917
- console.error("\n Timed out.");
918
- } catch (e) {
919
- console.error("Failed:", e.message);
1622
+ if (spinnerTimer) {
1623
+ clearInterval(spinnerTimer);
1624
+ process.stdout.write("\r" + " ".repeat(80) + "\r");
1625
+ }
1626
+ console.error(` \u2717 Timed out after ${Math.round((d.expires_in || 600) / 60)} minutes.`);
1627
+ process.exit(1);
1628
+ } finally {
1629
+ if (spinnerTimer) clearInterval(spinnerTimer);
920
1630
  }
1631
+ }
1632
+ auth.command("signup").description("Create an account via browser (device flow)").action(() => runDeviceFlow("signup"));
1633
+ auth.command("login").description("Log in via browser (device flow)").action(() => runDeviceFlow("login"));
1634
+ auth.command("logout").description("Remove stored credentials").action(() => {
1635
+ const c = loadConfig();
1636
+ delete c.apiKey;
1637
+ saveConfig(c);
1638
+ console.log(` \u2713 Logged out. Config kept at ${CONFIG_FILE}.`);
921
1639
  });
922
1640
  auth.command("set-key").description("Set API key directly").argument("<key>").action((key) => {
923
1641
  const c = loadConfig();
924
1642
  c.apiKey = key;
925
1643
  saveConfig(c);
926
- console.log("API key saved.");
1644
+ console.log(" \u2713 API key saved.");
927
1645
  });
928
1646
  auth.command("status").description("Show auth status").action(() => {
929
1647
  const c = loadConfig();
930
- console.log(c.apiKey ? `Authenticated: ${c.apiKey.slice(0, 15)}...` : "Not authenticated");
1648
+ console.log(c.apiKey ? ` \u2713 Authenticated: ${c.apiKey.slice(0, 15)}...${c.apiKey.slice(-4)}` : " \u2717 Not authenticated. Run: tcloud auth signup");
931
1649
  const w = loadWallets();
932
- if (w.length) console.log(`Shielded wallets: ${w.length}`);
1650
+ if (w.length) console.log(` Shielded wallets: ${w.length}`);
1651
+ });
1652
+ auth.command("whoami").description("Show logged-in account details").action(async () => {
1653
+ const c = loadConfig();
1654
+ if (!c.apiKey) {
1655
+ console.log(" \u2717 Not authenticated. Run: tcloud auth signup");
1656
+ return;
1657
+ }
1658
+ try {
1659
+ const res = await fetch(`${c.apiUrl}/api/auth/userinfo`, {
1660
+ headers: { Authorization: `Bearer ${c.apiKey}` }
1661
+ });
1662
+ if (!res.ok) {
1663
+ console.log(` \u2717 Auth check failed (${res.status}). Your key may be revoked \u2014 run: tcloud auth login`);
1664
+ return;
1665
+ }
1666
+ const me = await res.json();
1667
+ const user = me.user ?? {};
1668
+ const sub = me.subscription;
1669
+ console.log(` Email: ${user.email ?? "n/a"}`);
1670
+ console.log(` User: ${user.name ?? user.id ?? "n/a"}`);
1671
+ console.log(` Plan: ${sub?.plan ?? "free"}`);
1672
+ console.log(` Balance: $${Number(me.balance ?? 0).toFixed(4)}`);
1673
+ console.log(` Key: ${c.apiKey.slice(0, 15)}...${c.apiKey.slice(-4)}`);
1674
+ console.log(` API: ${c.apiUrl}`);
1675
+ } catch (e) {
1676
+ console.log(` \u2717 ${e.message ?? e}`);
1677
+ }
1678
+ });
1679
+ program.command("signup").description("Create an account via browser (alias for `auth signup`)").action(() => runDeviceFlow("signup"));
1680
+ program.command("login").description("Log in via browser (alias for `auth login`)").action(() => runDeviceFlow("login"));
1681
+ program.command("logout").description("Remove stored credentials (alias for `auth logout`)").action(() => {
1682
+ const c = loadConfig();
1683
+ delete c.apiKey;
1684
+ saveConfig(c);
1685
+ console.log(` \u2713 Logged out.`);
1686
+ });
1687
+ program.command("whoami").description("Show logged-in account (alias for `auth whoami`)").action(async () => {
1688
+ const c = loadConfig();
1689
+ if (!c.apiKey) {
1690
+ console.log(" \u2717 Not authenticated. Run: tcloud signup");
1691
+ return;
1692
+ }
1693
+ try {
1694
+ const res = await fetch(`${c.apiUrl}/api/auth/userinfo`, { headers: { Authorization: `Bearer ${c.apiKey}` } });
1695
+ if (!res.ok) {
1696
+ console.log(` \u2717 Auth failed (${res.status}). Run: tcloud login`);
1697
+ return;
1698
+ }
1699
+ const me = await res.json();
1700
+ const user = me.user ?? {};
1701
+ const sub = me.subscription;
1702
+ console.log(` ${user.email ?? user.name ?? user.id} \xB7 $${Number(me.balance ?? 0).toFixed(4)} \xB7 ${sub?.plan ?? "free"}`);
1703
+ } catch (e) {
1704
+ console.log(` \u2717 ${e.message ?? e}`);
1705
+ }
933
1706
  });
934
1707
  var wallet = program.command("wallet").description("Shielded wallet management");
935
1708
  wallet.command("generate").description("Generate ephemeral wallet").option("-l, --label <name>").action((opts) => {
@@ -1050,7 +1823,10 @@ credits.command("add").description("Add credits").argument("<amount>").action(as
1050
1823
  const client = getClient();
1051
1824
  try {
1052
1825
  const data = await client.addCredits(parseFloat(amount));
1053
- console.log(`Credits added. New balance: $${data.balance.toFixed(4)}`);
1826
+ if (data.url) {
1827
+ console.log(`Checkout URL: ${data.url}`);
1828
+ console.log("Complete payment to add credits.");
1829
+ }
1054
1830
  } catch (e) {
1055
1831
  console.error("Error:", e.message);
1056
1832
  }