@tangle-network/tcloud
Version:
TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls
1,746 lines • 62.1 kB
JavaScript
import { t as packageVersion } from "./version-BwTKR1na.js";
//#region src/private-router.ts
function secureRandom() {
const arr = /* @__PURE__ */ new Uint32Array(1);
crypto.getRandomValues(arr);
return arr[0] / 4294967296;
}
var PrivateRouter = class {
config;
operators = [];
usage = /* @__PURE__ */ new Map();
currentIndex = 0;
totalRequests = 0;
/** Slug of the most-recently-selected operator. Populated on each selectOperator() hit. */
_lastSelectedSlug = null;
constructor(config = {}) {
this.config = {
strategy: config.strategy || "round-robin",
maxRequestsPerOperator: config.maxRequestsPerOperator || 5,
minOperators: config.minOperators || 3,
preferRegions: config.preferRegions,
excludeOperators: config.excludeOperators,
summarizeOnSwitch: config.summarizeOnSwitch ?? false
};
}
/** Set the available operator pool */
setOperators(operators) {
let filtered = operators.filter((o) => !this.config.excludeOperators?.includes(o.slug));
if (this.config.preferRegions?.length) filtered.sort((a, b) => {
return (this.config.preferRegions.includes(a.region) ? 0 : 1) - (this.config.preferRegions.includes(b.region) ? 0 : 1);
});
this.operators = filtered;
}
/** Select the next operator for a request */
selectOperator(model) {
const eligible = this.operators.filter((o) => o.models.includes(model));
if (eligible.length === 0) return null;
if (eligible.length < this.config.minOperators) {
console.warn(`[PrivateRouter] Only ${eligible.length} eligible operator(s) for model "${model}", but minOperators requires ${this.config.minOperators}. Refusing to route.`);
return null;
}
this.totalRequests++;
switch (this.config.strategy) {
case "round-robin": return this.roundRobin(eligible);
case "random": return this.random(eligible);
case "geo-distributed": return this.geoDistributed(eligible);
case "min-exposure": return this.minExposure(eligible);
case "latency-aware": return this.latencyAware(eligible);
default: return this.roundRobin(eligible);
}
}
/** Should we summarize context before this request? (operator is changing) */
shouldSummarize(model) {
if (!this.config.summarizeOnSwitch) return false;
const next = this.peekNextOperator(model);
const last = this.getLastUsedOperator();
return next !== null && last !== null && next.slug !== last.slug;
}
/** Get privacy stats */
getStats() {
return {
totalRequests: this.totalRequests,
operatorsUsed: this.usage.size,
operatorBreakdown: Array.from(this.usage.values()).map((u) => ({
slug: u.slug,
requests: u.requestCount,
lastUsed: u.lastUsedAt
})),
strategy: this.config.strategy
};
}
roundRobin(eligible) {
const op = eligible[this.currentIndex % eligible.length];
this.currentIndex++;
this.recordUsage(op);
return op;
}
random(eligible) {
const op = eligible[Math.floor(secureRandom() * eligible.length)];
this.recordUsage(op);
return op;
}
geoDistributed(eligible) {
const regionUsage = /* @__PURE__ */ new Map();
for (const op of eligible) {
const usage = this.usage.get(op.slug)?.requestCount || 0;
const current = regionUsage.get(op.region) || 0;
regionUsage.set(op.region, current + usage);
}
const targetRegion = [...regionUsage.entries()].sort((a, b) => a[1] - b[1])[0]?.[0];
const regionOps = eligible.filter((o) => o.region === targetRegion);
const op = regionOps[Math.floor(secureRandom() * regionOps.length)] || eligible[0];
this.recordUsage(op);
return op;
}
minExposure(eligible) {
const lastUsed = this.getLastUsedOperator();
if (lastUsed) {
const lastUsage = this.usage.get(lastUsed.slug);
const others = eligible.filter((o) => o.slug !== lastUsed.slug);
if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) {
const op = others.sort((a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0))[0];
this.recordUsage(op);
return op;
}
}
const op = [...eligible].sort((a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0))[0];
this.recordUsage(op);
return op;
}
latencyAware(eligible) {
const weights = eligible.map((o) => {
const latencyWeight = 1 / Math.max(o.avgLatencyMs, 10);
const usagePenalty = (this.usage.get(o.slug)?.requestCount || 0) * .1;
return Math.max(latencyWeight - usagePenalty, .01);
});
const totalWeight = weights.reduce((s, w) => s + w, 0);
let r = secureRandom() * totalWeight;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) {
this.recordUsage(eligible[i]);
return eligible[i];
}
}
const op = eligible[eligible.length - 1];
this.recordUsage(op);
return op;
}
recordUsage(op) {
const existing = this.usage.get(op.slug);
this.usage.set(op.slug, {
slug: op.slug,
requestCount: (existing?.requestCount || 0) + 1,
lastUsedAt: Date.now()
});
this._lastSelectedSlug = op.slug;
}
/** Slug of the most-recently-selected operator (null before the first call). */
get lastSelectedSlug() {
return this._lastSelectedSlug;
}
getLastUsedOperator() {
let latest = null;
for (const u of this.usage.values()) if (!latest || u.lastUsedAt > latest.lastUsedAt) latest = u;
if (!latest) return null;
return this.operators.find((o) => o.slug === latest.slug) || null;
}
peekNextOperator(model) {
const eligible = this.operators.filter((o) => o.models.includes(model));
if (eligible.length === 0) return null;
if (eligible.length < this.config.minOperators) return null;
const last = this.getLastUsedOperator();
switch (this.config.strategy) {
case "round-robin": return eligible[this.currentIndex % eligible.length];
case "min-exposure":
if (last) {
const lastUsage = this.usage.get(last.slug);
const others = eligible.filter((o) => o.slug !== last.slug);
if (others.length > 0 && lastUsage && lastUsage.requestCount > 0) return others.sort((a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0))[0];
}
return [...eligible].sort((a, b) => (this.usage.get(a.slug)?.requestCount || 0) - (this.usage.get(b.slug)?.requestCount || 0))[0];
case "geo-distributed": {
const regionUsage = /* @__PURE__ */ new Map();
for (const op of eligible) {
const usage = this.usage.get(op.slug)?.requestCount || 0;
const current = regionUsage.get(op.region) || 0;
regionUsage.set(op.region, current + usage);
}
const targetRegion = [...regionUsage.entries()].sort((a, b) => a[1] - b[1])[0]?.[0];
return eligible.filter((o) => o.region === targetRegion)[0] || eligible[0];
}
default:
if (last && eligible.length > 1) return eligible.find((o) => o.slug !== last.slug) || eligible[0];
return eligible[0];
}
}
};
//#endregion
//#region src/decisions.ts
/** Parse the allowed-model set out of a router `model_not_allowed` refusal —
* both refusal shapes name it after `Allowed:`. Null when the message
* carries no set (an unexpected shape; do not guess). */
function parseModelNotAllowed(message) {
const match = /Allowed:\s*([^\n]+?)(?:\.\s*)?$/.exec(message);
if (!match?.[1]) return null;
const allowed = match[1].split(",").map((id) => id.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
return allowed.length > 0 ? allowed : null;
}
/** Resolve a model request against a key's allow-list: unscoped or permitted
* → allowed; a permitted fallback → rerouted (the blocked model named);
* nothing permitted → refused with the set (fail loud, never silently
* downgrade — a different model is a different decision-maker). */
function resolveModelWithinAllowList(input) {
const allowed = input.allowedModels;
if (!allowed || allowed.length === 0) return {
status: "allowed",
model: input.requested,
rerouted: false
};
if (allowed.includes(input.requested)) return {
status: "allowed",
model: input.requested,
rerouted: false
};
const fallback = (input.fallbacks ?? []).find((id) => allowed.includes(id));
if (fallback !== void 0) return {
status: "rerouted",
model: fallback,
rerouted: true,
blockedModel: input.requested
};
return {
status: "refused",
model: null,
allowedModels: allowed
};
}
/** The loud, correctable refusal: blocked model, allowed set, the fix. */
function formatModelNotAllowedRefusal(input) {
return `Model "${input.requested}" is not in this key's allow-list. Allowed: ${input.allowedModels.join(", ")}. Route to an allowed model or widen the key’s allow-list.`;
}
const MIN_CHILD_KEY_BUDGET_USD = .5;
/** The source key is exhausted — correctable, both numbers on the error. */
var ParentBudgetExhaustedError = class extends Error {
requestedUsd;
parentRemainingUsd;
constructor(requestedUsd, parentRemainingUsd) {
super(`workspace source key has no budget remaining ($${parentRemainingUsd.toFixed(2)}) — top up or raise its cap before sandbox turns (the mint would have asked $${requestedUsd.toFixed(2)})`);
this.name = "ParentBudgetExhaustedError";
this.requestedUsd = requestedUsd;
this.parentRemainingUsd = parentRemainingUsd;
}
};
/** Derive a child key's budget ask from the parent's remaining budget:
* unknown parent → the configured ask stands; exhausted → correctable
* error before any mint; otherwise max(min, min(requested, floor-to-cents
* remaining)). Never above a known remaining, never below the platform
* minimum. */
function deriveChildBudgetUsd(input) {
const minChildUsd = input.minChildUsd ?? .5;
if (!Number.isFinite(input.requestedUsd) || input.requestedUsd <= 0) throw new Error(`deriveChildBudgetUsd: requestedUsd must be a finite positive USD amount (got ${input.requestedUsd})`);
const ceiling = input.parentRemainingUsd;
if (!(typeof ceiling === "number" && Number.isFinite(ceiling))) return input.requestedUsd;
if (ceiling <= 0) throw new ParentBudgetExhaustedError(input.requestedUsd, ceiling);
return Math.max(minChildUsd, Math.min(input.requestedUsd, Math.floor(ceiling * 100) / 100));
}
//#endregion
//#region src/client.ts
/** Cost + identity receipts off a decision-surface response. */
function receiptsFrom(res) {
const num = (name) => {
const raw = res.headers.get(name);
if (raw === null) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
};
return {
generationId: res.headers.get("X-Generation-Id"),
servedModel: res.headers.get("X-Tangle-Served-Model"),
costUsd: num("X-Tangle-Cost-USD"),
providerCostUsd: num("X-Tangle-Provider-Cost-USD"),
maxCostUsd: num("X-Tangle-Max-Cost-USD"),
replayed: res.headers.get("X-Idempotent-Replay") === "true"
};
}
/** Non-enumerable marker set on clients constructed via {@link TCloudClient.rotating}. */
const ROTATING_MARKER = "__tcloudRotating";
/** Non-enumerable marker set on clients constructed via {@link TCloudClient.fromCliBridge}. */
const DIRECT_CLI_BRIDGE_MARKER = "__tcloudDirectCliBridge";
const DEFAULT_BASE_URL = "https://router.tangle.tools/v1";
/**
* Route a fetch call through the configured privacy proxy.
* - direct: standard fetch
* - relayer: POST to relayer's /relay/proxy or /relay/proxy-stream
*/
async function proxiedFetch(privacy, url, init, streaming) {
if (!privacy || privacy.mode === "direct") return fetch(url, init);
if (privacy.mode !== "relayer") throw new Error(`Unsupported privacy mode: ${String(privacy.mode)}`);
if (!privacy.relayerUrl) throw new Error("relayerUrl is required when privacy mode is \"relayer\"");
const proxyPath = streaming ? "/relay/proxy-stream" : "/relay/proxy";
const headers = {};
if (init.headers) {
const entries = init.headers instanceof Headers ? Array.from(init.headers.entries()) : Object.entries(init.headers);
for (const [key, value] of entries) headers[key] = value;
}
return fetch(`${privacy.relayerUrl}${proxyPath}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: init.signal,
body: JSON.stringify({
target: url,
body: typeof init.body === "string" ? JSON.parse(init.body) : init.body,
headers
})
});
}
/** A retry wait owned by the SDK, cancellable without leaving a timer behind. */
function retryDelay(ms, signal) {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, ms);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
});
}
/** Absent, malformed, negative and infinite amounts are not a zero-cost receipt. */
function nonnegativeAmount(value) {
if (value === null || value === void 0 || value === "") return void 0;
if (typeof value !== "number" && typeof value !== "string") return void 0;
if (typeof value === "string" && value.trim() === "") return void 0;
const amount = Number(value);
return Number.isFinite(amount) && amount >= 0 ? amount : void 0;
}
const DEFAULT_RETRY = {
maxRetries: 3,
initialBackoffMs: 500,
maxBackoffMs: 3e4,
multiplier: 2,
retryableStatuses: [
429,
500,
502,
503,
504
]
};
const DEFAULT_TIMEOUT_MS = 6e4;
const DEFAULT_PLATFORM_URL = "https://id.tangle.tools";
const PROTECTED_PROVIDER_OPTION_KEYS = /* @__PURE__ */ new Set([
"model",
"messages",
"temperature",
"max_tokens",
"maxTokens",
"stream",
"stop",
"top_p",
"topP",
"frequency_penalty",
"frequencyPenalty",
"presence_penalty",
"presencePenalty",
"response_format",
"responseFormat",
"tools",
"tool_choice",
"toolChoice",
"plugins",
"webSearch",
"gateway",
"bridge",
"agent_profile",
"agentProfile",
"session_id",
"sessionId"
]);
function sanitizeProviderOptions(providerOptions) {
if (!providerOptions) return {};
for (const key of Object.keys(providerOptions)) if (PROTECTED_PROVIDER_OPTION_KEYS.has(key)) throw new Error(`providerOptions cannot override protected chat field "${key}"; use the typed ChatOptions field instead`);
return providerOptions;
}
var TCloudClient = class TCloudClient {
baseURL;
platformURL;
apiKey;
model;
headers;
spendAuthFn;
privacy;
limits;
retryConfig;
timeoutMs;
_totalSpent = 0;
_requestCount = 0;
privateRouter;
_cachedOperators = [];
_operatorsCachedAt = 0;
static OPERATORS_TTL_MS = 3e5;
/**
* Build a client pointed directly at a cli-bridge instance — skips the
* Tangle Router entirely. cli-bridge serves the OpenAI-compatible
* `/v1/chat/completions` endpoint natively, so chat() / ask() /
* chatStream() work as-is against any local or remote bridge.
*
* Use this when you have your own cli-bridge running (locally or on
* your own VPS) and don't need router-side gating, billing, or
* observability — your CLI subscriptions on the bridge box pay for
* the LLM tokens directly.
*
* Wire form: model id is `<harness>/<model>` (e.g. `claude-code/sonnet`,
* `kimi-code/kimi-for-coding`) — no `bridge/` prefix needed in direct
* mode; cli-bridge accepts the harness id as the first path segment.
*
* ```ts
* const client = TCloudClient.fromCliBridge({
* url: 'http://127.0.0.1:3344',
* bearer: process.env.CLI_BRIDGE_BEARER!,
* })
* const reply = await client.ask('explain X', 'claude-code/sonnet')
* ```
*
* For session-resumable agentic dispatches, use `client.bridge(...)` on
* the returned direct client. `resume` is serialized to cli-bridge's
* `session_id` body field and the model wire format stays
* `<harness>/<model>` without the router-only `bridge/` prefix.
*/
static fromCliBridge(opts) {
const baseURL = opts.url.replace(/\/+$/, "") + "/v1";
const client = new TCloudClient({
...opts.config ?? {},
apiKey: opts.bearer,
baseURL
});
Object.defineProperty(client, DIRECT_CLI_BRIDGE_MARKER, {
value: true,
enumerable: false,
configurable: false,
writable: false
});
return client;
}
/**
* Build a client that rotates which operator serves each call. Mirrors
* {@link TCloudClient.shielded} in shape: returns a standard `TCloudClient`
* that behaves identically for the OpenAI-compatible surface but
* dispatches each chat/completions/embeddings request through a
* {@link PrivateRouter} — different operator per call per the chosen
* strategy.
*
* ```ts
* const tcloud = TCloudClient.rotating({
* apiKey: process.env.TANGLE_API_KEY,
* routing: { strategy: 'min-exposure' },
* })
* await tcloud.ask('hello')
* tcloud.getRotationStats() // { callsByOperator: { ... }, currentOperator: '…' }
* ```
*
* Rotation is meaningful only for stateless calls. Sandbox-harness
* sessions bind to a single operator for the lifetime of the session;
* `rotating()` clients refuse to dispatch them — see {@link bridge}.
*/
static rotating(config = {}) {
const routing = config.routing ?? {};
const strategy = routing.strategy ?? "min-exposure";
const { routing: _omit, ...base } = config;
const client = new TCloudClient(base);
const router = new PrivateRouter({
strategy,
minOperators: routing.minOperators ?? 1,
maxRequestsPerOperator: routing.maxRequestsPerOperator,
excludeOperators: routing.excludeOperators,
preferRegions: routing.preferRegions
});
if (routing.pool && routing.pool.length > 0) {
router.setOperators(routing.pool);
client._cachedOperators = routing.pool;
client._operatorsCachedAt = Date.now();
}
client.privateRouter = router;
Object.defineProperty(client, ROTATING_MARKER, {
value: true,
enumerable: false,
configurable: false,
writable: false
});
return client;
}
constructor(config = {}) {
this.baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/$/, "");
this.platformURL = (config.platformURL || DEFAULT_PLATFORM_URL).replace(/\/$/, "");
this.apiKey = config.apiKey || process.env.TANGLE_API_KEY || process.env.TCLOUD_API_KEY;
this.model = config.model || "gpt-4o-mini";
this.privacy = config.privacy;
this.limits = config.limits;
this.retryConfig = config.retry === false ? null : {
...DEFAULT_RETRY,
...config.retry
};
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
this.headers = {
"Content-Type": "application/json",
"X-Tangle-Client": `tcloud-sdk/${packageVersion()}`
};
if (this.apiKey) this.headers["Authorization"] = `Bearer ${this.apiKey}`;
if (config.routing?.mode) this.headers["X-Tangle-Routing"] = config.routing.mode;
if (config.routing?.prefer) this.headers["X-Tangle-Operator"] = config.routing.prefer;
if (config.routing?.blueprintId) this.headers["X-Tangle-Blueprint"] = config.routing.blueprintId;
if (config.routing?.serviceId) this.headers["X-Tangle-Service"] = config.routing.serviceId;
if (config.routing?.region) this.headers["X-Tangle-Region"] = config.routing.region;
if (config.routing?.strategy) {
const strategyMap = {
"round-robin": "round-robin",
"lowest-latency": "latency-aware",
"lowest-price": "round-robin",
"highest-reputation": "round-robin"
};
this.privateRouter = new PrivateRouter({ strategy: strategyMap[config.routing.strategy] || "round-robin" });
}
}
/** Set the SpendAuth signer for private mode */
setSpendAuthSigner(fn) {
this.spendAuthFn = fn;
}
/** Current metering stats */
get usage() {
return {
totalSpent: this._totalSpent,
requestCount: this._requestCount,
limits: this.limits ? { ...this.limits } : void 0
};
}
/** Check spending limits before a request. Throws TCloudError if blocked. */
checkLimits() {
if (!this.limits) return;
if (this.limits.maxRequests && this._requestCount >= this.limits.maxRequests) {
this.limits.onLimitReached?.({
type: "requests",
current: this._requestCount,
limit: this.limits.maxRequests
});
throw new TCloudError(429, `Request limit reached (${this._requestCount}/${this.limits.maxRequests})`);
}
if (this.limits.maxTotalSpend && this._totalSpent >= this.limits.maxTotalSpend) {
this.limits.onLimitReached?.({
type: "total",
current: this._totalSpent,
limit: this.limits.maxTotalSpend
});
throw new TCloudError(429, `Spending limit reached ($${this._totalSpent.toFixed(6)}/$${this.limits.maxTotalSpend})`);
}
if (this.limits.maxRequests && this.limits.onLimitWarning) {
if (this._requestCount / this.limits.maxRequests >= .8) this.limits.onLimitWarning({
type: "requests",
current: this._requestCount,
limit: this.limits.maxRequests
});
}
if (this.limits.maxTotalSpend && this.limits.onLimitWarning) {
if (this._totalSpent / this.limits.maxTotalSpend >= .8) this.limits.onLimitWarning({
type: "total",
current: this._totalSpent,
limit: this.limits.maxTotalSpend
});
}
}
/** Ensure the private router has operators loaded (with TTL-based caching) */
async ensureRouterOperators() {
if (!this.privateRouter) return;
const now = Date.now();
if (this._cachedOperators.length > 0 && now - this._operatorsCachedAt < TCloudClient.OPERATORS_TTL_MS) return;
const data = await this.operators();
this._cachedOperators = (data.operators || []).map((op) => ({
slug: op.slug,
endpointUrl: op.endpointUrl,
region: "",
reputationScore: op.reputationScore,
avgLatencyMs: op.avgLatencyMs,
models: op.models.map((m) => m.modelId)
}));
this._operatorsCachedAt = now;
this.privateRouter.setOperators(this._cachedOperators);
}
/** Track cost after a response, using actual pricing from response headers when available */
trackCost(completion, res) {
this._requestCount++;
const usage = completion.usage;
const billed = nonnegativeAmount(res?.headers.get("x-tangle-cost-usd")) ?? nonnegativeAmount(usage?.billed_cost) ?? nonnegativeAmount(usage?.cost);
let estimatedCost;
if (billed !== void 0) {
estimatedCost = billed;
completion.tangle = {
costUsd: billed,
costSource: "receipt"
};
} else if (usage) {
const inputPrice = nonnegativeAmount(res?.headers.get("x-tangle-price-input"));
const outputPrice = nonnegativeAmount(res?.headers.get("x-tangle-price-output"));
const inputTokens = nonnegativeAmount(usage.prompt_tokens);
const outputTokens = nonnegativeAmount(usage.completion_tokens);
if (inputPrice !== void 0 && outputPrice !== void 0 && inputTokens !== void 0 && outputTokens !== void 0) {
estimatedCost = inputTokens * inputPrice + outputTokens * outputPrice;
completion.tangle = {
costUsd: estimatedCost,
costSource: "rates"
};
} else estimatedCost = (usage.total_tokens || 0) * 1e-6;
} else return;
this._totalSpent += estimatedCost;
if (this.limits?.maxCostPerRequest && estimatedCost > this.limits.maxCostPerRequest) this.limits.onLimitReached?.({
type: "cost",
current: estimatedCost,
limit: this.limits.maxCostPerRequest
});
}
/**
* Core fetch with retry + timeout. All helpers build on this.
* Retries on retryable status codes with exponential backoff + jitter.
*/
async _doFetch(url, init, streaming) {
const retry = this.retryConfig;
const maxAttempts = retry ? retry.maxRetries + 1 : 1;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
init.signal?.throwIfAborted();
const controller = new AbortController();
let timer;
if (this.timeoutMs > 0 && !streaming) timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await proxiedFetch(this.privacy, url, {
...init,
signal: init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal
}, streaming);
if (res.ok) return res;
if (retry && attempt < retry.maxRetries && retry.retryableStatuses.includes(res.status)) {
const backoff = Math.min(retry.initialBackoffMs * Math.pow(retry.multiplier, attempt), retry.maxBackoffMs);
const jitter = backoff * .5 * Math.random();
await res.body?.cancel().catch(() => {});
await retryDelay(backoff + jitter, init.signal);
continue;
}
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
} catch (e) {
init.signal?.throwIfAborted();
if (e instanceof TCloudError) throw e;
if (retry && attempt < retry.maxRetries) {
await retryDelay(Math.min(retry.initialBackoffMs * Math.pow(retry.multiplier, attempt), retry.maxBackoffMs), init.signal);
continue;
}
if (e?.name === "AbortError") throw new TCloudError(408, `Request timed out after ${this.timeoutMs}ms`);
throw new TCloudError(0, e?.message || "Network error");
} finally {
if (timer !== void 0) clearTimeout(timer);
}
}
throw new TCloudError(0, "Retry loop exhausted");
}
/**
* Shared request helper for billable calls that keep the Response (the
* decision surfaces read cost/identity receipts off its headers).
*/
async _requestWithResponse(url, init = {}) {
this.checkLimits();
const res = await this._doFetch(url, {
...init,
headers: {
...this.headers,
...init.headers
}
}, false);
this._requestCount++;
return res;
}
/**
* Shared request helper for billable JSON API calls.
* Enforces: checkLimits → fetch with retry/timeout → error parsing → requestCount.
*/
async _request(url, init = {}) {
this.checkLimits();
const res = await this._doFetch(url, {
headers: this.headers,
...init
}, false);
this._requestCount++;
return res.json();
}
/**
* Shared request helper for read-only/non-billable JSON API calls.
* No limits check, no request counting.
*/
async _fetch(url, init = {}) {
return (await this._doFetch(url, {
headers: this.headers,
...init
}, false)).json();
}
/**
* Shared request helper for billable calls that return non-JSON (e.g. ArrayBuffer).
*/
async _requestRaw(url, init = {}) {
this.checkLimits();
const res = await this._doFetch(url, {
headers: this.headers,
...init
}, false);
this._requestCount++;
return res;
}
/**
* Prepare headers for chat requests — operator routing + SpendAuth +
* bridge short-circuit headers when `options.bridge` is set.
* Shared between chat() and chatStream() to eliminate duplication.
*/
async _prepareChatRequest(model, bridge) {
const headers = { ...this.headers };
if (this.spendAuthFn) {
const auth = await this.spendAuthFn();
headers["X-Payment-Signature"] = JSON.stringify(auth);
delete headers["Authorization"];
}
let baseURL = this.baseURL;
if (this.privateRouter) {
await this.ensureRouterOperators();
const operator = this.privateRouter.selectOperator(model);
if (operator) {
baseURL = operator.endpointUrl.replace(/\/$/, "");
headers["X-Tangle-Operator"] = operator.slug;
delete headers["Authorization"];
}
}
if (bridge && !this._isDirectCliBridge()) {
headers["X-Bridge-Unlock"] = bridge.unlock ?? "";
if (bridge.resume) headers["X-Resume"] = bridge.resume;
if (bridge.bridgeUrl) headers["X-Bridge-Url"] = bridge.bridgeUrl;
if (bridge.bridgeBearer) headers["X-Bridge-Bearer"] = bridge.bridgeBearer;
}
return {
headers,
baseURL
};
}
/**
* Resolve the effective model string. When a bridge is set, rewrite to
* `bridge/<harness>/<model>` (or `bridge/<harness>` if no model).
*/
_effectiveModel(options) {
if (options.bridge) {
if (this._isDirectCliBridge()) return options.bridge.model ? `${options.bridge.harness}/${options.bridge.model}` : options.bridge.harness;
return options.bridge.model ? `bridge/${options.bridge.harness}/${options.bridge.model}` : `bridge/${options.bridge.harness}`;
}
return options.model || this.model;
}
/** Build the chat completions request body */
_chatBody(options, stream) {
const providerOptions = sanitizeProviderOptions(options.providerOptions);
const gateway = options.gateway || options.webSearch !== void 0 ? {
...options.gateway,
...options.webSearch !== void 0 && options.gateway?.webSearch === void 0 ? { webSearch: options.webSearch } : {}
} : void 0;
const sandboxBody = {};
if (options.sandbox?.agentProfile) sandboxBody.agent_profile = options.sandbox.agentProfile;
if (options.sandbox?.sessionId) sandboxBody.session_id = options.sandbox.sessionId;
if (this._isDirectCliBridge() && options.bridge?.resume && !sandboxBody.session_id) sandboxBody.session_id = options.bridge.resume;
return JSON.stringify({
...providerOptions,
model: this._effectiveModel(options),
messages: options.messages,
temperature: options.temperature,
max_tokens: options.maxTokens,
stream,
stop: options.stop,
top_p: options.topP,
frequency_penalty: options.frequencyPenalty,
presence_penalty: options.presencePenalty,
response_format: options.responseFormat,
tools: options.tools,
tool_choice: options.toolChoice,
plugins: options.plugins,
...gateway ? { gateway } : {},
...sandboxBody
});
}
/** Chat completion (non-streaming) */
async chat(options) {
options.signal?.throwIfAborted();
this.checkLimits();
const { headers, baseURL } = await this._prepareChatRequest(this._effectiveModel(options), options.bridge);
const res = await this._doFetch(`${baseURL}/chat/completions`, {
method: "POST",
headers,
body: this._chatBody(options, false),
signal: options.signal
}, false);
const completion = await res.json();
delete completion.tangle;
this.trackCost(completion, res);
return completion;
}
/** Chat completion (streaming) — returns an async iterator of chunks */
async *chatStream(options) {
this.checkLimits();
this._requestCount++;
const { headers, baseURL } = await this._prepareChatRequest(this._effectiveModel(options), options.bridge);
const reader = (await this._doFetch(`${baseURL}/chat/completions`, {
method: "POST",
headers,
body: this._chatBody(options, true),
signal: options.signal
}, true)).body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow — server sent >1MB without newline");
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch {}
}
}
}
/**
* Bridge — scoped helper for a subscription-backed CLI harness behind
* the Tangle Router's cli-bridge. Returns a mini-client bound to
* (harness, unlock, resume) so you don't thread those through every
* call.
*
* ```ts
* const kimi = tcloud.bridge({ harness: 'kimi-code', model: 'kimi-for-coding', unlock: UNLOCK, resume: 'pr-42' })
* await kimi.ask('review this diff…')
* for await (const chunk of kimi.stream('continue…')) process.stdout.write(chunk)
* ```
*
* Sessions persist across process restarts — use the same `resume` id
* to land on the same CLI conversation (context intact, no replay tax).
*
* Guard: clients built via {@link TCloudClient.rotating} cannot dispatch
* sandbox-harness sessions (rotation rotates per call; a sandbox session
* binds to one operator). Attempting `bridge({ harness: 'sandbox' })` on
* a rotating client throws.
*/
bridge(cfg) {
if (cfg.harness === "sandbox" && this[ROTATING_MARKER] === true) throw new Error("TCloudClient.rotating() cannot dispatch sandbox-harness sessions.\nSandbox sessions bind to a single operator; rotation is meaningful only for\nstateless calls. Use TCloudClient.shielded() + AgentProfile.confidential.tee\nfor privacy-preserving sandbox execution instead.");
if (!this._isDirectCliBridge() && cfg.unlock == null) throw new Error("Bridge unlock is required for router-mediated bridge sessions");
return new BridgeSession(this, cfg, this._isDirectCliBridge());
}
_isDirectCliBridge() {
return this[DIRECT_CLI_BRIDGE_MARKER] === true;
}
/**
* Rotation stats — populated only on clients created via
* {@link TCloudClient.rotating}. Non-rotating clients return an empty
* counter and `currentOperator: null`.
*/
getRotationStats() {
if (!this.privateRouter) return {
callsByOperator: {},
currentOperator: null
};
const callsByOperator = {};
for (const row of this.privateRouter.getStats().operatorBreakdown) callsByOperator[row.slug] = row.requests;
return {
callsByOperator,
currentOperator: this.privateRouter.lastSelectedSlug
};
}
/** Convenience: send a single message and get the text response */
async ask(message, modelOrOptions) {
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
return (await this.chat({
messages: [{
role: "user",
content: message
}],
...options
})).choices[0]?.message?.content || "";
}
/** Convenience: send a single message and get the full completion (with usage) */
async askFull(message, modelOrOptions) {
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
return this.chat({
messages: [{
role: "user",
content: message
}],
...options
});
}
/** Convenience: stream a single message and yield text chunks */
async *askStream(message, modelOrOptions) {
const options = typeof modelOrOptions === "string" ? { model: modelOrOptions } : modelOrOptions;
for await (const chunk of this.chatStream({
messages: [{
role: "user",
content: message
}],
...options
})) {
const content = chunk.choices[0]?.delta?.content;
if (content) yield content;
}
}
/** List available models */
async models() {
return (await this._fetch(`${this.baseURL}/models`)).data || [];
}
/** List active operators */
async operators() {
const apiRoot = this.baseURL.replace(/\/v1$/, "");
return this._fetch(`${apiRoot}/api/operators`);
}
/** Get credit balance */
async credits() {
const { data } = await this._fetch(`${this.platformURL}/v1/billing/balance`);
return data;
}
/** Add credits via Stripe checkout. Returns the checkout URL. */
async addCredits(amount) {
const { data } = await this._fetch(`${this.platformURL}/v1/billing/topup`, {
method: "POST",
body: JSON.stringify({ amount })
});
return data;
}
/** Get transaction history */
async transactions(limit = 50) {
const { data } = await this._fetch(`${this.platformURL}/v1/billing/transactions?limit=${limit}`);
return data;
}
/**
* Create a new API key.
* When called with an API key (not session), the new key is automatically
* a child of the calling key — enabling hierarchical key delegation.
*
* Pass `parentKeyId` explicitly to create a child of a specific key.
* Child keys inherit the parent's product scope, allowedModels, and rpmLimit
* if not specified. Budget cannot exceed the parent's remaining budget.
*/
/** The OpenAI Decisions wire, natively: `client.decisions.create(...)`. */
decisions = { create: (options) => this._decisionsCreate(options) };
async _decisionsCreate(options) {
const headers = {};
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
if (options.maxCostUsd !== void 0) headers["X-Tangle-Max-Cost-USD"] = String(options.maxCostUsd);
const body = {
model: options.model,
input: options.input,
questions: options.questions,
...options.safetyIdentifier !== void 0 ? { safety_identifier: options.safetyIdentifier } : {},
...options.promptCacheKey !== void 0 ? { prompt_cache_key: options.promptCacheKey } : {}
};
const res = await this._requestWithResponse(`${this.baseURL}/decisions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options.signal
});
return {
decision: await res.json(),
receipts: receiptsFrom(res)
};
}
/** The System One wire (fleet judges): state + named questions, answers by name. */
async systemone(options) {
const headers = {};
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
if (options.maxCostUsd !== void 0) headers["X-Tangle-Max-Cost-USD"] = String(options.maxCostUsd);
const res = await this._requestWithResponse(`${this.baseURL}/systemone`, {
method: "POST",
headers,
body: JSON.stringify({
model: options.model,
state: options.state,
questions: options.questions
}),
signal: options.signal
});
return {
result: await res.json(),
receipts: receiptsFrom(res)
};
}
/**
* This key's own scope, live: `GET /v1/key` — the allow-list in its
* positive form (null = unscoped), budgets, rate limit, expiry. Scoped
* callers route within `allowedModels` BEFORE a first refusal.
*/
async keyInfo() {
const data = (await this._fetch(`${this.baseURL}/key`))?.data ?? {};
const allowed = data.allowed_models;
const usage = data.usage ?? {};
Number(usage.monthly ?? 0);
const remaining = typeof data.limit_remaining === "number" ? data.limit_remaining : null;
return {
allowedModels: Array.isArray(allowed) && allowed.length > 0 ? allowed : null,
limit: typeof data.limit === "number" ? data.limit : null,
limitRemaining: remaining,
limitReset: data.limit_reset === "monthly" ? "monthly" : null,
rateLimitRequests: typeof data.rate_limit === "object" && data.rate_limit !== null ? typeof data.rate_limit.requests === "number" ? data.rate_limit.requests : null : null,
expiresAt: typeof data.expires_at === "string" ? data.expires_at : null
};
}
_allowedModelsCache = null;
static ALLOWED_MODELS_TTL_MS = 3e5;
/**
* The key's model allow-list (null when unscoped), cached five minutes.
* `refresh: true` forces a re-read — key rotations and allow-list edits
* land here first.
*/
async allowedModels(options = {}) {
const now = Date.now();
if (!options.refresh && this._allowedModelsCache && now - this._allowedModelsCache.at < TCloudClient.ALLOWED_MODELS_TTL_MS) return this._allowedModelsCache.models;
const info = await this.keyInfo();
this._allowedModelsCache = {
models: info.allowedModels,
at: now
};
return info.allowedModels;
}
/**
* Resolve a model request against this key's allow-list (live, cached):
* permitted → `{status:'allowed'}`; a permitted fallback → rerouted with
* the blocked model named; nothing permitted → refused carrying the set —
* never a silent downgrade. Throws nothing; callers surface `refused`
* with `formatModelNotAllowedRefusal`.
*/
async resolveModel(requested, options = {}) {
return resolveModelWithinAllowList({
requested,
allowedModels: await this.allowedModels({ refresh: options.refresh }),
fallbacks: options.fallbacks
});
}
async createKey(opts) {
const body = { ...opts };
if (opts.parentBudgetRemainingUsd !== void 0) {
body.budgetUsd = opts.budgetUsd !== void 0 ? deriveChildBudgetUsd({
requestedUsd: opts.budgetUsd,
parentRemainingUsd: opts.parentBudgetRemainingUsd
}) : void 0;
delete body.parentBudgetRemainingUsd;
if (body.budgetUsd === void 0) delete body.budgetUsd;
}
const { data } = await this._fetch(`${this.platformURL}/v1/keys`, {
method: "POST",
body: JSON.stringify(body)
});
return data;
}
/** Get a single API key by ID */
async getKey(id) {
const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`);
return data;
}
/**
* List API keys.
* Pass `children: true` to list child keys of the calling API key.
*/
async keys(opts) {
const q = opts?.children ? "?children=true" : "";
const { data } = await this._fetch(`${this.platformURL}/v1/keys${q}`);
return data;
}
/**
* Update an API key's limits.
* Can adjust budget, allowedModels, rpmLimit, expiresAt, and name.
*/
async updateKey(id, updates) {
const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}`, {
method: "PATCH",
body: JSON.stringify(updates)
});
return data;
}
/** Revoke an API key. If the key has children, they are also revoked recursively. */
async revokeKey(id) {
await this._fetch(`${this.platformURL}/v1/keys/${id}`, { method: "DELETE" });
}
/** Rotate an API key — creates new key with same config, revokes old */
async rotateKey(id) {
const { data } = await this._fetch(`${this.platformURL}/v1/keys/${id}/rotate`, { method: "POST" });
return data;
}
/** Create a project for usage attribution */
async createProject(name, product) {
const { data } = await this._fetch(`${this.platformURL}/v1/projects`, {
method: "POST",
body: JSON.stringify({
name,
product
})
});
return data;
}
/** List projects */
async projects() {
const { data } = await this._fetch(`${this.platformURL}/v1/projects`);
return data;
}
/** Generate embeddings */
async embeddings(options) {
return this._request(`${this.baseURL}/embeddings`, {
method: "POST",
body: JSON.stringify({
model: options.model || "text-embedding-3-small",
input: options.input
})
});
}
/** Generate images */
async imageGenerate(options) {
return this._request(`${this.baseURL}/images/generations`, {
method: "POST",
body: JSON.stringify({
model: options.model || "dall-e-3",
prompt: options.prompt,
n: options.n,
size: options.size,
quality: options.quality,
response_format: options.response_format
})
});
}
/**
* Edit / inpaint / variate an existing image with a text prompt.
* Sibling to `imageGenerate`; routes to `/v1/images/edits` via
* multipart/form-data per the OpenAI spec.
*
* Reference image attachments may be passed as `Blob`, `ArrayBuffer`,
* or `{data: base64, mediaType, filename?}`. Multi-image composition
* (e.g. gpt-image-2 with two reference frames + a prompt that fuses
* them) is supported by passing an array; for legacy models only the
* first image is honored upstream.
*/
async imagesEdit(options) {
const formData = new FormData();
formData.append("prompt", options.prompt);
formData.append("model", options.model || "gpt-image-2");
if (options.n != null) formData.append("n", String(options.n));
if (options.size) formData.append("size", options.size);
if (options.quality) formData.append("quality", options.quality);
if (options.response_format) formData.append("response_format", options.response_format);
if (options.mask) formData.append("mask", toEditBlob(options.mask, "mask.png"), "mask.png");
const images = Array.isArray(options.image) ? options.image : [options.image];
if (images.length === 0) throw new TCloudError(400, "imagesEdit requires at least one image attachment");
images.forEach((img, idx) => {
const blob = toEditBlob(img, `image-${idx + 1}.png`);
formData.append("image[]", blob, blobFilename(img, `image-${idx + 1}.png`));
});
const headers = { ...this.headers };
delete headers["Content-Type"];
this.checkLimits();
const res = await proxiedFetch(this.privacy, `${this.baseURL}/images/edits`, {
method: "POST",
headers,
body: formData
}, false);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
}
this._requestCount++;
return res.json();
}
/** Rerank documents by relevance to a query */
async rerank(options) {
return this._request(`${this.baseURL}/rerank`, {
method: "POST",
body: JSON.stringify({
model: options.model || "rerank-english-v3.0",
query: options.query,
documents: options.documents,
top_n: options.top_n
})
});
}
/** Search the web through Tangle Router billing and provider routing. */
async search(options) {
return this._request(`${this.baseURL}/search`, {
method: "POST",
signal: options.signal,
body: JSON.stringify({
query: options.query,
provider: options.provider,
model: options.model,
maxResults: options.maxResults,
searchRecency: options.searchRecency,
includeDomains: options.includeDomains,
excludeDomains: options.excludeDomains
})
});
}
/** Run a multi-step deep-research task through Tangle Router billing and
* provider routing (POST /v1/research). Slower and costlier than `search` —
* the provider synthesizes an answer over many fetches. Pick depth with
* `effort` (provider-specific vocabulary; see {@link ResearchOptions.effort}). */
async research(options) {
return this._request(`${this.baseURL}/research`, {
method: "POST",
body: JSON.stringify({
query: options.query,
provider: options.provider,
model: options.model,
effort: options.effort,
maxResults: options.maxResults,
searchRecency: options.searchRecency,
includeDomains: options.includeDomains,
excludeDomains: options.excludeDomains,
outputSchema: options.outputSchema
})
});
}
/** Text-to-speech */
async speech(options) {
return (await this._requestRaw(`${this.baseURL}/audio/speech`, {
method: "POST",
body: JSON.stringify({
model: options.model || "tts-1",
input: options.input,
voice: options.voice || "alloy"
})
})).arrayBuffer();
}
/** Legacy completions endpoint */
async completions(options) {
return this._request(`${this.baseURL}/completions`, {
method: "POST",
body: JSON.stringify({
model: options.model || this.model,
prompt: options.prompt,
temperature: options.temperature,
max_tokens: options.maxTokens,
stop: options.stop,
top_p: options.topP
})
});
}
/** Audio transcription (speech-to-text) */
async transcribe(file, options) {
const formData = new FormData();
formData.append("file", file, "audio.webm");
formData.append("model", options?.model || "whisper-1");
if (options?.language) formData.append("language", options.language);
if (options?.prompt) formData.append("prompt", options.prompt);
const headers = { ...this.headers };
delete headers["Content-Type"];
this.checkLimits();
const res = await proxiedFetch(this.privacy, `${this.baseURL}/audio/transcriptions`, {
method: "POST",
headers,
body: formData
}, false);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText);
}
this._requestCount++;
return res.json();
}
/** Create a fine-tuning job */
async fineTuneCreate(options) {
return this._request(`${this.baseURL}/fine_tuning/jobs`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** List fine-tuning jobs */
async fineTuneList() {
return this._fetch(`${this.baseURL}/fine_tuning/jobs`);
}
/** Submit a batch of chat requests */
async batch(requests) {
return this._request(`${this.baseURL}/batch`, {
method: "POST",
body: JSON.stringify({ requests })
});
}
/** Get batch job status */
async batchStatus(jobId) {
return this._fetch(`${this.baseURL}/batch?id=${jobId}`);
}
/** Generate video */
async videoGenerate(options) {
return this._request(`${this.baseURL}/videos`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** Get video generation status */
async videoStatus(id) {
return this._fetch(`${this.baseURL}/videos/${encodeURIComponent(id)}`);
}
/** Generate an avatar video (lip-synced talking head from audio + face image).
* Returns 202 with a job_id for async polling via avatarJobStatus(). */
async avatarGenerate(options) {
return this._request(`${this.baseURL}/avatar/generate`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** Poll an avatar generation job by ID. */
async avatarJobStatus(jobId) {
return this._fetch(`${this.baseURL}/avatar/jobs/${jobId}`);
}
/** Poll an avatar job until it reaches a terminal state (completed/failed).
* Returns the final job status. Throws on failure. */
async pollAvatarJob(jobId, options) {
const interval = options?.intervalMs ?? 5e3;
const timeout = options?.timeoutMs ?? 3e5;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const job = await this.avatarJobStatus(jobId);
if (job.status === "completed") return job;
if (job.status === "failed") throw new TCloudError(500, job.error || `Avatar job ${jobId} failed`);
await new Promise((r) => setTimeout(r, interval));
}
throw new TCloudError(408, `Avatar job ${jobId} timed out after ${timeout}ms`);
}
/**
* Watch an async job via SSE until it reaches a terminal state.
* Works with avatar, video, and training blueprint operators.
*
* @param jobId - The job ID returned by the creation endpoint
* @param options - Optional: operatorUrl override, onEvent callback
* @returns The final JobEvent (completed/failed/cancelled)
*/
async watchJob(jobId, options) {
const url = `${options?.operatorUrl?.replace(/\/$/, "") || this.baseURL}/v1/jobs/${encodeURIComponent(jobId)}/events`;
const timeout = options?.timeout ?? 3e5;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const watchHeaders = {
...this.headers,
Accept: "text/event-stream"
};
if (options?.operatorUrl) delete watchHeaders["Authorization"];
if (options?.sseToken) watchHeaders["Authorization"] = `Bearer ${options.sseToken}`;
const res = await proxiedFetch(this.privacy, url, {
headers: watchHeaders,
signal: controller.signal
}, true);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new TCloudError(res.status, err.error?.message || err.error || res.statusText);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const terminalStatuses = /* @__PURE__ */ new Set([
"completed",
"failed",
"cancelled"
]);
while (true) {
const { done, value } = await reader.read();
if (done) throw new TCloudError(502, `SSE stream ended without terminal event for job ${jobId}`);
buf += decoder.decode(value, { stream: true });
if (buf.length > 1048576) throw new TCloudError(502, "SSE buffer overflow — server sent >1MB without newline");
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
try {
options?.onEvent?.(event);
} catch (cbErr) {
console.error("watchJob onEvent callback error:", cbErr);
}
if (terminalStatuses.has(event.status)) return event;
}
}
} catch (err) {
if (err?.name === "AbortError") throw new TCloudError(408, `Job ${jobId} timed out after ${timeout}ms`);
throw err;
} finally {
clearTimeout(timer);
}
}
/** Create a vector collection on the operator's vector store */
async createCollection(options) {
return this._request(`${this.baseURL}/collections`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** List collections on the operator's vector store */
async listCollections() {
return this._fetch(`${this.baseURL}/collections`);
}
/** Upsert vectors into a collection */
async upsertVectors(collection, vectors) {
return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/upsert`, {
method: "POST",
body: JSON.stringify({ vectors })
});
}
/** Similarity search in a collection */
async queryVectors(collection, options) {
return this._request(`${this.baseURL}/collections/${encodeURIComponent(collection)}/query`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** RAG query — embed text + search collection in one call */
async ragQuery(options) {
return this._request(`${this.baseURL}/rag`, {
method: "POST",
body: JSON.stringify(options)
});
}
/** Search models by name, provider, or capability */
async searchModels(query) {
const all = await this.models();
const q = query.toLowerCase();
return all.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m._provider && m._provider.toLowerCase().includes(q));
}
/** Estimate cost for a request (without sending it) */
async estimateCost(options) {
const model = (await this.models()).find((m) => m.id === (options.model || this.model));
if (!model) return {
inputCost: 0,
outputCost: 0,
total: 0
};
const inputCost = options.inputTokens * parseFloat(model.pricing.prompt);
const outputCost = options.outputTokens * parseFloat(model.pricing.completion);
return {
inputCost,
outputCost,
total: inputCost + outputCost
};
}
/**
* Get a pricing spectrum across resource tiers for a model.
*
* Uses REAL per-operator pricing from `operator.models[].inputPrice`.
* Each tier filters operators by GPU count and TEE capability, then
* reports the cheapest and most expensive operator for that config.
*
* @param options.model - Model ID to price (falls back to client default)
* @param options.tiers - Number of tiers (1-7, default 5)
*/
async pricingSpectrum(options) {
const requestedTiers = Math.max(1, Math.min(options.tiers ?? 5, ALL_TIERS.length));
const modelId = options.model || this.model;
const selected = selectTiers(ALL_TIERS, requestedTiers);
const allOperators = (await this.operators()).operators || [];
return selected.map((tier) => {
const matching = allOperators.filter((op) => {
if (tier.gpu > 0 && (op.gpuCount ?? 0) < tier.gpu) return false;
if (tier.tee && !op.teeAttested) return false;
return true;
});
const prices = matching.map((op) => op.models.find((m) => m.modelId === modelId)?.inputPrice).filter((p) => p != null && p > 0).sort((a, b) => a - b);
const cheapestPrice = prices[0];
const priciestPrice = prices.length > 1 ? prices[prices.length - 1] : void 0;
return {
tier: tier.name,
config: tier,
cheapestPrice,
priciestPrice: priciestPrice !== cheapestPrice ? priciestPrice : void 0,
cheapest: cheapestPrice != null ? formatPrice(cheapestPrice) : "no operators for this config",
priciest: priciestPrice != null && priciestPrice !== cheapestPrice ? formatPrice(priciestPrice) : void 0,
availableOperators: matching.length,
operatorsWithModel: prices.length
};
});
}
get _apiRoot() {
return this.baseURL.replace(/\/v1$/, "");
}
async eval(opts) {
return this._request(`${this._apiRoot}/api/eval`, {
method: "POST",
body: JSON.stringify(opts)
});
}
async createSuite(opts) {
return this._request(`${this._apiRoot}/api/eval/suites`, {
method: "POST",
body: JSON.stringify(opts)
});
}
async listSuites() {
return this._fetch(`${this._apiRoot}/api/eval/suites`);
}
async runSuite(suiteId, opts) {
return this._request(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`, {
method: "POST",
body: JSON.stringify(opts || {})
});
}
async listRuns(suiteId) {
return this._fetch(`${this._apiRoot}/api/eval/suites/${suiteId}/runs`);
}
async getRun(runId) {
return this._fetch(`${this._apiRoot}/api/eval/runs/${runId}`);
}
async setBaseline(runId) {
await this._request(`${this._apiRoot}/api/eval/runs/${runId}`, {
method: "PATCH",
body: JSON.stringify({ baseline: true })
});
}
async sandboxPricing(opts) {
const p = new URLSearchParams();
if (opts?.cpu) p.set("cpu", String(opts.cpu));
if (opts?.ram) p.set("ram", String(opts.ram));
if (opts?.disk) p.set("disk", String(opts.disk));
return this._fetch(`${this._apiRoot}/api/sandbox/pricing?${p}`);
}
async sandboxStatus() {
return this._fetch(`${this._apiRoot}/api/sandbox/link-key`);
}
async sandboxProvision() {
return this._request(`${this._apiRoot}/api/sandbox/provision`, { method: "POST" });
}
async sandboxCreate(opts) {
return this._request(`${this._apiRoot}/api/sandbox/sessions`, {
method: "POST",
body: JSON.stringify(opts)
});
}
async sandboxList() {
return this._fetch(`${this._apiRoot}/api/sandbox/sessions`);
}
async sandboxStats(sandboxId) {
return this._fetch(`${this._apiRoot}/api/sandbox/stats/${sandboxId}`);
}
async sandboxDestroy(sessionId) {
return this._request(`${this._apiRoot}/api/sandbox/sessions/${sessionId}`, { method: "DELETE" });
}
async userInfo() {
return this._fetch(`${this._apiRoot}/api/auth/userinfo`);
}
};
const ALL_TIERS = [
{
name: "cpu-only",
cpu: 4,
ramGb: 16,
gpu: 0,
tee: false
},
{
name: "gpu",
cpu: 8,
ramGb: 32,
gpu: 1,
tee: false
},
{
name: "gpu-tee",
cpu: 8,
ramGb: 32,
gpu: 1,
tee: true
},
{
name: "multi-gpu",
cpu: 32,
ramGb: 128,
gpu: 2,
tee: false
},
{
name: "multi-gpu-tee",
cpu: 32,
ramGb: 128,
gpu: 2,
tee: true
},
{
name: "max-gpu",
cpu: 64,
ramGb: 256,
gpu: 4,
tee: false
},
{
name: "max-gpu-tee",
cpu: 64,
ramGb: 256,
gpu: 4,
tee: true
}
];
/** Select N evenly-spaced items, always including first and last. */
/**
* BridgeSession — a chat client scoped to one bridge configuration.
*
* Instead of threading `{ harness, unlock, resume }` through every
* `chat()` call, create a session once and call `ask` / `stream` / `chat`
* on it. The session's `resume` id is stable across calls so follow-up
* turns land on the same CLI conversation.
*
* ```ts
* const tcloud = new TCloudClient({ apiKey, baseURL: 'https://router.tangle.tools/api' })
* const kimi = tcloud.bridge({ harness: 'kimi-code', model: 'kimi-for-coding', unlock: UNLOCK, resume: 'pr-42' })
*
* // one-shot
* const reply = await kimi.ask('summarize this diff')
*
* // streaming
* for await (const chunk of kimi.stream('continue…')) process.stdout.write(chunk)
*
* // full OpenAI-shaped request
* const completion = await kimi.chat({ messages, temperature: 0.2 })
*
* // new resume id for a different logical conversation
* const kimiOther = kimi.withResume('ticket-123')
* ```
*/
var BridgeSession = class BridgeSession {
client;
cfg;
direct;
constructor(client, cfg, direct = false) {
this.client = client;
this.cfg = cfg;
this.direct = direct;
}
/** Full chat completion (non-streaming). */
async chat(options) {
return this.client.chat({
...options,
bridge: this.cfg
});
}
/** Stream OpenAI chat.completion.chunks. */
chatStream(options) {
return this.client.chatStream({
...options,
bridge: this.cfg
});
}
/** One-shot: send a string, get the assistant text. */
async ask(message, extra) {
return (await this.chat({
messages: [{
role: "user",
content: message
}],
...extra
})).choices[0]?.message?.content || "";
}
/** One-shot: send a string, stream text deltas. */
async *stream(message, extra) {
for await (const chunk of this.chatStream({
messages: [{
role: "user",
content: message
}],
...extra
})) {
const content = chunk.choices?.[0]?.delta?.content;
if (content) yield content;
}
}
/** Turn-based: send full message history, get assistant text. */
async turn(messages, extra) {
return (await this.chat({
messages,
...extra
})).choices[0]?.message?.content || "";
}
/** Clone with a new resume id — same harness, different logical conversation. */
withResume(resume) {
return new BridgeSession(this.client, {
...this.cfg,
resume
}, this.direct);
}
/** Clone with a different model inside the same harness. */
withModel(model) {
return new BridgeSession(this.client, {
...this.cfg,
model
}, this.direct);
}
/** The effective model id that will land on the router (`bridge/<harness>/<model>`). */
get model() {
const prefix = this.direct ? "" : "bridge/";
return this.cfg.model ? `${prefix}${this.cfg.harness}/${this.cfg.model}` : `${prefix}${this.cfg.harness}`;
}
/** The resume id currently bound to this session, if any. */
get resume() {
return this.cfg.resume;
}
};
function selectTiers(all, n) {
if (n >= all.length) return [...all];
if (n <= 1) return [all[0]];
if (n === 2) return [all[0], all[all.length - 1]];
const result = [all[0]];
const step = (all.length - 1) / (n - 1);
for (let i = 1; i < n - 1; i++) result.push(all[Math.round(i * step)]);
result.push(all[all.length - 1]);
return result;
}
function formatPrice(pricePerToken) {
return `$${(pricePerToken * 1e3).toFixed(6)}/1K tokens`;
}
/**
* Normalize the polymorphic `ImageEditAttachment` to a `Blob` for
* multipart FormData submission. Base64 inputs are decoded; raw
* ArrayBuffers are wrapped with a sensible default media type (PNG).
*/
function toEditBlob(input, defaultFilename) {
if (input instanceof Blob) return input;
if (input instanceof ArrayBuffer) return new Blob([input], { type: "image/png" });
const binary = base64ToUint8Array(input.data);
const copy = new Uint8Array(binary.byteLength);
copy.set(binary);
return new Blob([copy.buffer], { type: input.mediaType || "image/png" });
}
/** Extract a stable filename for the upload (multipart needs one;
* upstream sometimes uses extension to sniff the format). */
function blobFilename(input, fallback) {
if (typeof input === "object" && !(input instanceof Blob) && !(input instanceof ArrayBuffer)) {
if (input.filename) return input.filename;
}
return fallback;
}
function base64ToUint8Array(b64) {
const m = /^data:[^;,]*(?:;[^,]*)?,(.+)$/.exec(b64);
const raw = m ? m[1] : b64;
const bin = typeof atob === "function" ? atob(raw) : globalThis.Buffer.from(raw, "base64").toString("binary");
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
var TCloudError = class extends Error {
status;
constructor(status, message) {
super(message);
this.status = status;
this.name = "TCloudError";
}
};
//#endregion
export { ParentBudgetExhaustedError as a, parseModelNotAllowed as c, MIN_CHILD_KEY_BUDGET_USD as i, resolveModelWithinAllowList as l, TCloudClient as n, deriveChildBudgetUsd as o, TCloudError as r, formatModelNotAllowedRefusal as s, BridgeSession as t, PrivateRouter as u };