UNPKG

langsmith

Version:

Client library to connect to the LangSmith Observability and Evaluation Platform.

603 lines (602 loc) 23.5 kB
/** * Main SandboxClient class for interacting with the sandbox server API. */ import { getLangSmithEnvironmentVariable } from "../utils/env.js"; import { _getFetchImplementation } from "../singletons/fetch.js"; import { AsyncCaller } from "../utils/async_caller.js"; import { Sandbox } from "./sandbox.js"; import { LangSmithResourceCreationError, LangSmithResourceNameConflictError, LangSmithResourceNotFoundError, LangSmithResourceTimeoutError, LangSmithSandboxAPIError, LangSmithValidationError, } from "./errors.js"; import { handleClientHttpError, handleSandboxCreationError, validateTtl, } from "./helpers.js"; /** * 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 = getLangSmithEnvironmentVariable("ENDPOINT") ?? "https://api.smith.langchain.com"; return `${base.replace(/\/$/, "")}/v2/sandboxes`; } /** * Get the default API key from environment. */ function getDefaultApiKey() { return 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(); * } * ``` */ export 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 = _getFetchImplementation(); this._caller = new 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 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 LangSmithValidationError("At most one of snapshotId or options.snapshotName may be set", "snapshotId"); } validateTtl(idleTtlSeconds, "idleTtlSeconds"); 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 handleSandboxCreationError(response); } const data = (await response.json()); return new 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 LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await handleClientHttpError(response); } const data = (await response.json()); return new 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 LangSmithSandboxAPIError(`API endpoint not found: ${url}. Check that apiEndpoint is correct.`); } await handleClientHttpError(response); } const data = await response.json(); return (data.sandboxes ?? []).map((s) => new Sandbox(s, this)); } async updateSandbox(name, newNameOrOptions) { const options = typeof newNameOrOptions === "string" ? { newName: newNameOrOptions } : newNameOrOptions; const { newName, idleTtlSeconds, deleteAfterStopSeconds } = options; validateTtl(idleTtlSeconds, "idleTtlSeconds"); 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 LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } if (response.status === 409) { throw new LangSmithResourceNameConflictError(newName !== undefined ? `Sandbox name '${newName}' already in use` : "Sandbox update conflict (name may already be in use)", "sandbox"); } await handleClientHttpError(response); } const data = (await response.json()); return new 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 LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await 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 LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await 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 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 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 LangSmithResourceNotFoundError(`Snapshot '${snapshotId}' not found`, "snapshot"); } await 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 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 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 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 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(); } }