langsmith
Version:
Client library to connect to the LangSmith Observability and Evaluation Platform.
607 lines (606 loc) • 23.9 kB
JavaScript
;
/**
* Main SandboxClient class for interacting with the sandbox server API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SandboxClient = void 0;
const env_js_1 = require("../utils/env.cjs");
const fetch_js_1 = require("../singletons/fetch.cjs");
const async_caller_js_1 = require("../utils/async_caller.cjs");
const sandbox_js_1 = require("./sandbox.cjs");
const errors_js_1 = require("./errors.cjs");
const helpers_js_1 = require("./helpers.cjs");
/**
* Sleep that can be interrupted by an AbortSignal.
* Resolves after `ms` milliseconds or rejects immediately if the signal fires.
*/
function sleepWithSignal(ms, signal) {
if (!signal) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(signal.reason);
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Get the default sandbox API endpoint from environment.
*
* Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT).
*/
function getDefaultApiEndpoint() {
const base = (0, env_js_1.getLangSmithEnvironmentVariable)("ENDPOINT") ??
"https://api.smith.langchain.com";
return `${base.replace(/\/$/, "")}/v2/sandboxes`;
}
/**
* Get the default API key from environment.
*/
function getDefaultApiKey() {
return (0, env_js_1.getLangSmithEnvironmentVariable)("API_KEY");
}
/**
* Client for interacting with the Sandbox Server API.
*
* This client provides a simple interface for managing sandboxes and snapshots.
*
* @example
* ```typescript
* import { SandboxClient } from "langsmith/sandbox";
*
* // Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
* const client = new SandboxClient();
*
* // Or with explicit configuration
* const client = new SandboxClient({
* apiEndpoint: "https://api.smith.langchain.com/v2/sandboxes",
* apiKey: "your-api-key",
* });
*
* // Create a sandbox with the default runtime
* const sandbox = await client.createSandbox();
* try {
* const result = await sandbox.run("python --version");
* console.log(result.stdout);
* } finally {
* await sandbox.delete();
* }
* ```
*/
class SandboxClient {
constructor(config = {}) {
Object.defineProperty(this, "_baseUrl", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_apiKey", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_defaultHeaders", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_fetchImpl", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_caller", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this._baseUrl = (config.apiEndpoint ?? getDefaultApiEndpoint()).replace(/\/$/, "");
this._apiKey = config.apiKey ?? getDefaultApiKey();
this._defaultHeaders = { ...(config.headers ?? {}) };
this._fetchImpl = (0, fetch_js_1._getFetchImplementation)();
this._caller = new async_caller_js_1.AsyncCaller({
maxRetries: config.maxRetries ?? 3,
maxConcurrency: config.maxConcurrency ?? Infinity,
});
}
/**
* Internal fetch method that adds authentication headers.
*
* Uses AsyncCaller to handle retries for transient failures
* (network errors, 5xx, 429).
*
* @internal
*/
async _fetch(url, init = {}) {
const headers = new Headers(init.headers);
if (this._apiKey) {
headers.set("X-Api-Key", this._apiKey);
}
for (const [name, value] of Object.entries(this._defaultHeaders)) {
if (!headers.has(name)) {
headers.set(name, value);
}
}
return this._caller.call(() => this._fetchImpl(url, {
...init,
headers,
}));
}
/**
* Get the API key for WebSocket authentication.
* @internal
*/
getApiKey() {
return this._apiKey;
}
/**
* Get the constructor-supplied default headers. Used by the WebSocket exec
* path so headers like `X-Service-Key` set on the client are attached to
* the WS upgrade request.
* @internal
*/
getDefaultHeaders() {
return { ...this._defaultHeaders };
}
/**
* JSON POST helper. Sends JSON body, checks response status,
* and returns the Response for further processing.
* Throws on non-ok responses via handleClientHttpError.
* Callers can add specific status checks (e.g. 404) before calling this.
* @internal
*/
async _postJson(url, body, options) {
const response = await this._fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) {
await (0, helpers_js_1.handleClientHttpError)(response);
}
return response;
}
async createSandbox(snapshotIdOrOptions, options = {}) {
const snapshotId = typeof snapshotIdOrOptions === "string" ? snapshotIdOrOptions : undefined;
const resolvedOptions = typeof snapshotIdOrOptions === "object" && snapshotIdOrOptions !== null
? snapshotIdOrOptions
: options;
const { snapshotName, name, timeout = 30, waitForReady = true, idleTtlSeconds, deleteAfterStopSeconds, vCpus, memBytes, fsCapacityBytes, proxyConfig, } = resolvedOptions;
if (snapshotId && snapshotName) {
throw new errors_js_1.LangSmithValidationError("At most one of snapshotId or options.snapshotName may be set", "snapshotId");
}
(0, helpers_js_1.validateTtl)(idleTtlSeconds, "idleTtlSeconds");
(0, helpers_js_1.validateTtl)(deleteAfterStopSeconds, "deleteAfterStopSeconds");
const url = `${this._baseUrl}/boxes`;
const payload = {
wait_for_ready: waitForReady,
};
if (snapshotId) {
payload.snapshot_id = snapshotId;
}
if (snapshotName) {
payload.snapshot_name = snapshotName;
}
if (waitForReady) {
payload.timeout = timeout;
}
if (name) {
payload.name = name;
}
if (idleTtlSeconds !== undefined) {
payload.idle_ttl_seconds = idleTtlSeconds;
}
if (deleteAfterStopSeconds !== undefined) {
payload.delete_after_stop_seconds = deleteAfterStopSeconds;
}
if (vCpus !== undefined) {
payload.vcpus = vCpus;
}
if (memBytes !== undefined) {
payload.mem_bytes = memBytes;
}
if (fsCapacityBytes !== undefined) {
payload.fs_capacity_bytes = fsCapacityBytes;
}
if (proxyConfig !== undefined) {
payload.proxy_config = proxyConfig;
}
const httpTimeout = waitForReady ? (timeout + 30) * 1000 : 30 * 1000;
const response = await this._fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(httpTimeout),
});
if (!response.ok) {
await (0, helpers_js_1.handleSandboxCreationError)(response);
}
const data = (await response.json());
return new sandbox_js_1.Sandbox(data, this);
}
/**
* Get a Sandbox by name.
*
* The sandbox is NOT automatically deleted. Use deleteSandbox() for cleanup.
*
* @param name - Sandbox name.
* @returns Sandbox.
* @throws LangSmithResourceNotFoundError if sandbox not found.
*/
async getSandbox(name, options) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`;
const response = await this._fetch(url, { signal: options?.signal });
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox");
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
const data = (await response.json());
return new sandbox_js_1.Sandbox(data, this);
}
/**
* List all Sandboxes.
*
* @returns List of Sandboxes.
*/
async listSandboxes() {
const url = `${this._baseUrl}/boxes`;
const response = await this._fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithSandboxAPIError(`API endpoint not found: ${url}. Check that apiEndpoint is correct.`);
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
const data = await response.json();
return (data.sandboxes ?? []).map((s) => new sandbox_js_1.Sandbox(s, this));
}
async updateSandbox(name, newNameOrOptions) {
const options = typeof newNameOrOptions === "string"
? { newName: newNameOrOptions }
: newNameOrOptions;
const { newName, idleTtlSeconds, deleteAfterStopSeconds } = options;
(0, helpers_js_1.validateTtl)(idleTtlSeconds, "idleTtlSeconds");
(0, helpers_js_1.validateTtl)(deleteAfterStopSeconds, "deleteAfterStopSeconds");
if (newName === undefined &&
idleTtlSeconds === undefined &&
deleteAfterStopSeconds === undefined) {
return this.getSandbox(name);
}
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`;
const payload = {};
if (newName !== undefined) {
payload.name = newName;
}
if (idleTtlSeconds !== undefined) {
payload.idle_ttl_seconds = idleTtlSeconds;
}
if (deleteAfterStopSeconds !== undefined) {
payload.delete_after_stop_seconds = deleteAfterStopSeconds;
}
const response = await this._fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox");
}
if (response.status === 409) {
throw new errors_js_1.LangSmithResourceNameConflictError(newName !== undefined
? `Sandbox name '${newName}' already in use`
: "Sandbox update conflict (name may already be in use)", "sandbox");
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
const data = (await response.json());
return new sandbox_js_1.Sandbox(data, this);
}
/**
* Delete a Sandbox.
*
* @param name - Sandbox name.
* @throws LangSmithResourceNotFoundError if sandbox not found.
*/
async deleteSandbox(name) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`;
const response = await this._fetch(url, { method: "DELETE" });
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox");
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
}
/**
* Get the provisioning status of a sandbox.
*
* This is a lightweight endpoint designed for polling during async creation.
* Use this instead of getSandbox() when you only need the status.
*
* @param name - Sandbox name.
* @returns ResourceStatus with status and optional status_message.
* @throws LangSmithResourceNotFoundError if sandbox not found.
*/
async getSandboxStatus(name, options) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/status`;
const response = await this._fetch(url, { signal: options?.signal });
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox");
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
return (await response.json());
}
/**
* Wait for a sandbox to become ready.
*
* Polls getSandboxStatus() until the sandbox reaches "ready" or "failed" status,
* then returns the full Sandbox object.
*
* @param name - Sandbox name.
* @param options - Polling options (timeout, pollInterval).
* @returns Ready Sandbox.
* @throws LangSmithResourceCreationError if sandbox status becomes "failed".
* @throws LangSmithResourceTimeoutError if timeout expires while still provisioning.
* @throws LangSmithResourceNotFoundError if sandbox not found.
*
* @example
* ```typescript
* const sandbox = await client.createSandbox(snapshot.id, { waitForReady: false });
* // ... do other work ...
* const readySandbox = await client.waitForSandbox(sandbox.name);
* ```
*/
async waitForSandbox(name, options = {}) {
const { timeout = 120, pollInterval = 1.0, signal } = options;
const deadline = Date.now() + timeout * 1000;
let lastStatus = "provisioning";
while (Date.now() < deadline) {
signal?.throwIfAborted();
const statusResult = await this.getSandboxStatus(name, { signal });
lastStatus = statusResult.status;
if (statusResult.status === "ready") {
return this.getSandbox(name, { signal });
}
if (statusResult.status === "failed") {
throw new errors_js_1.LangSmithResourceCreationError(statusResult.status_message ?? `Sandbox '${name}' creation failed`, "sandbox");
}
// Wait before polling again, capped to remaining time + jitter
const remaining = deadline - Date.now();
const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10%
const delay = Math.min(pollInterval * 1000 + jitter, remaining);
if (delay > 0) {
await sleepWithSignal(delay, signal);
}
}
throw new errors_js_1.LangSmithResourceTimeoutError(`Sandbox '${name}' did not become ready within ${timeout}s`, "sandbox", lastStatus);
}
/**
* Start a stopped sandbox and wait until ready.
*
* @param name - Sandbox name.
* @param options - Options with timeout.
* @returns Sandbox in "ready" status.
*/
async startSandbox(name, options = {}) {
const { timeout = 120, signal } = options;
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/start`;
await this._postJson(url, {}, { signal });
return this.waitForSandbox(name, { timeout, signal });
}
/**
* Stop a running sandbox (preserves sandbox files for later restart).
*
* @param name - Sandbox name.
*/
async stopSandbox(name) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/stop`;
await this._postJson(url, {});
}
// =========================================================================
// Snapshot Operations
// =========================================================================
/**
* Build a snapshot from a Docker image.
*
* Blocks until the snapshot is ready (polls with 2s interval).
*
* @param name - Snapshot name.
* @param dockerImage - Docker image to build from (e.g., "python:3.12-slim").
* @param fsCapacityBytes - Filesystem capacity in bytes.
* @param options - Additional options (registry credentials, timeout).
* @returns Snapshot in "ready" status.
*/
async createSnapshot(name, dockerImage, fsCapacityBytes, options = {}) {
const { registryId, registryUrl, registryUsername, registryPassword, timeout = 60, signal, } = options;
const url = `${this._baseUrl}/snapshots`;
const payload = {
name,
docker_image: dockerImage,
fs_capacity_bytes: fsCapacityBytes,
};
if (registryId !== undefined) {
payload.registry_id = registryId;
}
if (registryUrl !== undefined) {
payload.registry_url = registryUrl;
}
if (registryUsername !== undefined) {
payload.registry_username = registryUsername;
}
if (registryPassword !== undefined) {
payload.registry_password = registryPassword;
}
const response = await this._postJson(url, payload, { signal });
const snapshot = (await response.json());
return this.waitForSnapshot(snapshot.id, { timeout, signal });
}
/**
* Capture a snapshot from a running sandbox.
*
* Blocks until the snapshot is ready (polls with 2s interval).
*
* @param sandboxName - Name of the sandbox to capture from.
* @param name - Snapshot name.
* @param options - Capture options (timeout).
* @returns Snapshot in "ready" status.
*/
async captureSnapshot(sandboxName, name, options = {}) {
const { timeout = 60, signal } = options;
const url = `${this._baseUrl}/boxes/${encodeURIComponent(sandboxName)}/snapshot`;
const payload = { name };
const response = await this._postJson(url, payload, { signal });
const snapshot = (await response.json());
return this.waitForSnapshot(snapshot.id, { timeout, signal });
}
/**
* Get a snapshot by ID.
*
* @param snapshotId - Snapshot UUID.
* @returns Snapshot.
*/
async getSnapshot(snapshotId, options) {
const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`;
const response = await this._fetch(url, { signal: options?.signal });
if (!response.ok) {
if (response.status === 404) {
throw new errors_js_1.LangSmithResourceNotFoundError(`Snapshot '${snapshotId}' not found`, "snapshot");
}
await (0, helpers_js_1.handleClientHttpError)(response);
}
return (await response.json());
}
/**
* List snapshots, optionally filtered and paginated server-side.
*
* The backend always paginates this endpoint. When `limit` is omitted the
* server applies a default page size (currently 50), so a single call is
* not guaranteed to return every snapshot visible to the tenant. To iterate
* through all results, repeat the call with increasing `offset` values (or
* an explicit `limit`) until fewer than `limit` snapshots come back.
*
* @param options - Optional filter/pagination options.
* - `nameContains`: case-insensitive substring match on snapshot name.
* - `limit`: page size; must be between 1 and 500 (inclusive). Defaults
* to 50 server-side when omitted.
* - `offset`: number of snapshots to skip; must be `>= 0`.
*
* Values outside those ranges are rejected by the server.
* @returns A single page of Snapshots matching the provided filters.
*
* @example
* ```typescript
* const firstPage = await client.listSnapshots();
* const page = await client.listSnapshots({
* nameContains: "python",
* limit: 100,
* offset: 0,
* });
* ```
*/
async listSnapshots(options = {}) {
const { nameContains, limit, offset, signal } = options;
const params = new URLSearchParams();
if (nameContains !== undefined) {
params.set("name_contains", nameContains);
}
if (limit !== undefined) {
params.set("limit", String(limit));
}
if (offset !== undefined) {
params.set("offset", String(offset));
}
const query = params.toString();
const url = query
? `${this._baseUrl}/snapshots?${query}`
: `${this._baseUrl}/snapshots`;
const response = await this._fetch(url, { signal });
if (!response.ok) {
await (0, helpers_js_1.handleClientHttpError)(response);
}
const data = await response.json();
return (data.snapshots ?? []);
}
/**
* Delete a snapshot.
*
* @param snapshotId - Snapshot UUID.
*/
async deleteSnapshot(snapshotId) {
const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`;
const response = await this._fetch(url, { method: "DELETE" });
if (!response.ok) {
await (0, helpers_js_1.handleClientHttpError)(response);
}
}
/**
* Poll until a snapshot reaches "ready" or "failed" status.
*
* @param snapshotId - Snapshot UUID.
* @param options - Polling options (timeout, pollInterval).
* @returns Snapshot in "ready" status.
*/
async waitForSnapshot(snapshotId, options = {}) {
const { timeout = 300, pollInterval = 2.0, signal } = options;
const deadline = Date.now() + timeout * 1000;
let lastStatus = "building";
while (Date.now() < deadline) {
signal?.throwIfAborted();
const snapshot = await this.getSnapshot(snapshotId, { signal });
lastStatus = snapshot.status;
if (snapshot.status === "ready") {
return snapshot;
}
if (snapshot.status === "failed") {
throw new errors_js_1.LangSmithResourceCreationError(snapshot.status_message ?? `Snapshot '${snapshotId}' build failed`, "snapshot");
}
// Cap sleep to remaining time + jitter
const remaining = deadline - Date.now();
const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10%
const delay = Math.min(pollInterval * 1000 + jitter, remaining);
if (delay > 0) {
await sleepWithSignal(delay, signal);
}
}
throw new errors_js_1.LangSmithResourceTimeoutError(`Snapshot '${snapshotId}' did not become ready within ${timeout}s`, "snapshot", lastStatus);
}
/**
* Returns a string representation of the SandboxClient instance.
* This method is called when the object is converted to a string
* or logged, ensuring sensitive information like API keys is not exposed.
*
* @returns A string representation of the SandboxClient.
*/
toString() {
return `[LangSmithSandboxClient apiEndpoint=${JSON.stringify(this._baseUrl)}]`;
}
/**
* Custom inspect method for Node.js.
* This method is called when the object is inspected in the Node.js REPL
* or with console.log, ensuring sensitive information like API keys is not exposed.
*
* @returns A string representation of the SandboxClient for inspection.
*/
[Symbol.for("nodejs.util.inspect.custom")]() {
return this.toString();
}
}
exports.SandboxClient = SandboxClient;