UNPKG

@tangle-network/tcloud

Version:

TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls

1,746 lines • 62.1 kB
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 };