langsmith
Version:
Client library to connect to the LangSmith Observability and Evaluation Platform.
698 lines (697 loc) • 26.1 kB
JavaScript
/**
* 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, } from "./errors.js";
import { handleClientHttpError, handleConflictError, handlePoolError, handleResourceInUseError, handleSandboxCreationError, handleVolumeCreationError, } from "./helpers.js";
/**
* 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 templates.
*
* @example
* ```typescript
* import { SandboxClient } from "langsmith/experimental/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 and run commands
* const sandbox = await client.createSandbox("python-sandbox");
* 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, "_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._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);
}
return this._caller.call(() => this._fetchImpl(url, {
...init,
headers,
}));
}
// =========================================================================
// Volume Operations
// =========================================================================
/**
* Create a new persistent volume.
*
* Creates a persistent storage volume that can be referenced in templates.
*
* @param name - Volume name.
* @param options - Creation options including size and optional timeout.
* @returns Created Volume.
* @throws SandboxCreationError if volume provisioning fails.
* @throws ResourceTimeoutError if volume doesn't become ready within timeout.
*/
async createVolume(name, options) {
const { size, timeout = 60 } = options;
const url = `${this._baseUrl}/volumes`;
const payload = {
name,
size,
wait_for_ready: true,
timeout,
};
const response = await this._fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout((timeout + 30) * 1000),
});
if (!response.ok) {
await handleVolumeCreationError(response);
}
return (await response.json());
}
/**
* Get a volume by name.
*
* @param name - Volume name.
* @returns Volume.
* @throws LangSmithResourceNotFoundError if volume not found.
*/
async getVolume(name) {
const url = `${this._baseUrl}/volumes/${encodeURIComponent(name)}`;
const response = await this._fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Volume '${name}' not found`, "volume");
}
await handleClientHttpError(response);
}
return (await response.json());
}
/**
* List all volumes.
*
* @returns List of Volumes.
*/
async listVolumes() {
const url = `${this._baseUrl}/volumes`;
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.volumes ?? []);
}
/**
* Delete a volume.
*
* @param name - Volume name.
* @throws LangSmithResourceNotFoundError if volume not found.
* @throws ResourceInUseError if volume is referenced by templates.
*/
async deleteVolume(name) {
const url = `${this._baseUrl}/volumes/${encodeURIComponent(name)}`;
const response = await this._fetch(url, { method: "DELETE" });
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Volume '${name}' not found`, "volume");
}
if (response.status === 409) {
await handleResourceInUseError(response, "volume");
}
await handleClientHttpError(response);
}
}
/**
* Update a volume's name and/or size.
*
* You can update the display name, size, or both in a single request.
* Only storage size increases are allowed (storage backend limitation).
*
* @param name - Current volume name.
* @param options - Update options.
* @returns Updated Volume.
* @throws LangSmithResourceNotFoundError if volume not found.
* @throws ValidationError if storage decrease attempted.
* @throws LangSmithResourceNameConflictError if newName is already in use.
*/
async updateVolume(name, options) {
const { newName, size } = options;
if (newName === undefined && size === undefined) {
// Nothing to update, just return the current volume
return this.getVolume(name);
}
const url = `${this._baseUrl}/volumes/${encodeURIComponent(name)}`;
const payload = {};
if (newName !== undefined) {
payload.name = newName;
}
if (size !== undefined) {
payload.size = size;
}
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(`Volume '${name}' not found`, "volume");
}
if (response.status === 409) {
await handleConflictError(response, "volume");
}
await handleClientHttpError(response);
}
return (await response.json());
}
// =========================================================================
// Template Operations
// =========================================================================
/**
* Create a new SandboxTemplate.
*
* Only the container image, resource limits, and volume mounts can be
* configured. All other container details are handled by the server.
*
* @param name - Template name.
* @param options - Creation options including image and resource limits.
* @returns Created SandboxTemplate.
*/
async createTemplate(name, options) {
const { image, cpu = "500m", memory = "512Mi", storage, volumeMounts, } = options;
const url = `${this._baseUrl}/templates`;
const payload = {
name,
image,
resources: {
cpu,
memory,
},
};
if (storage) {
payload.resources.storage = storage;
}
if (volumeMounts && volumeMounts.length > 0) {
payload.volume_mounts = volumeMounts.map((vm) => ({
volume_name: vm.volume_name,
mount_path: vm.mount_path,
}));
}
const response = await this._fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
await handleClientHttpError(response);
}
return (await response.json());
}
/**
* Get a SandboxTemplate by name.
*
* @param name - Template name.
* @returns SandboxTemplate.
* @throws LangSmithResourceNotFoundError if template not found.
*/
async getTemplate(name) {
const url = `${this._baseUrl}/templates/${encodeURIComponent(name)}`;
const response = await this._fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Template '${name}' not found`, "template");
}
await handleClientHttpError(response);
}
return (await response.json());
}
/**
* List all SandboxTemplates.
*
* @returns List of SandboxTemplates.
*/
async listTemplates() {
const url = `${this._baseUrl}/templates`;
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.templates ?? []);
}
/**
* Update a template.
*
* @param name - Current template name.
* @param options - Update options (e.g., newName).
* @returns Updated SandboxTemplate.
* @throws LangSmithResourceNotFoundError if template not found.
* @throws LangSmithResourceNameConflictError if newName is already in use.
*/
async updateTemplate(name, options) {
const { newName } = options;
if (newName === undefined) {
// Nothing to update, just return the current template
return this.getTemplate(name);
}
const url = `${this._baseUrl}/templates/${encodeURIComponent(name)}`;
const payload = { name: newName };
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(`Template '${name}' not found`, "template");
}
if (response.status === 409) {
await handleConflictError(response, "template");
}
await handleClientHttpError(response);
}
return (await response.json());
}
/**
* Delete a SandboxTemplate.
*
* @param name - Template name.
* @throws LangSmithResourceNotFoundError if template not found.
* @throws ResourceInUseError if template is referenced by sandboxes or pools.
*/
async deleteTemplate(name) {
const url = `${this._baseUrl}/templates/${encodeURIComponent(name)}`;
const response = await this._fetch(url, { method: "DELETE" });
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Template '${name}' not found`, "template");
}
if (response.status === 409) {
await handleResourceInUseError(response, "template");
}
await handleClientHttpError(response);
}
}
// =========================================================================
// Pool Operations
// =========================================================================
/**
* Create a new Sandbox Pool.
*
* Pools pre-provision sandboxes from a template for faster startup.
*
* @param name - Pool name (lowercase letters, numbers, hyphens; max 63 chars).
* @param options - Creation options including templateName, replicas, and optional timeout.
* @returns Created Pool.
* @throws LangSmithResourceNotFoundError if template not found.
* @throws ValidationError if template has volumes attached.
* @throws ResourceAlreadyExistsError if pool with this name already exists.
* @throws ResourceTimeoutError if pool doesn't reach ready state within timeout.
* @throws QuotaExceededError if organization quota is exceeded.
*/
async createPool(name, options) {
const { templateName, replicas, timeout = 30 } = options;
const url = `${this._baseUrl}/pools`;
const payload = {
name,
template_name: templateName,
replicas,
wait_for_ready: true,
timeout,
};
const response = await this._fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout((timeout + 30) * 1000),
});
if (!response.ok) {
await handlePoolError(response);
}
return (await response.json());
}
/**
* Get a Pool by name.
*
* @param name - Pool name.
* @returns Pool.
* @throws LangSmithResourceNotFoundError if pool not found.
*/
async getPool(name) {
const url = `${this._baseUrl}/pools/${encodeURIComponent(name)}`;
const response = await this._fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Pool '${name}' not found`, "pool");
}
await handleClientHttpError(response);
}
return (await response.json());
}
/**
* List all Pools.
*
* @returns List of Pools.
*/
async listPools() {
const url = `${this._baseUrl}/pools`;
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.pools ?? []);
}
/**
* Update a Pool's name and/or replica count.
*
* You can update the display name, replica count, or both.
* The template reference cannot be changed after creation.
*
* @param name - Current pool name.
* @param options - Update options.
* @returns Updated Pool.
* @throws LangSmithResourceNotFoundError if pool not found.
* @throws ValidationError if template was deleted.
* @throws LangSmithResourceNameConflictError if newName is already in use.
* @throws QuotaExceededError if quota exceeded when scaling up.
*/
async updatePool(name, options) {
const { newName, replicas } = options;
if (newName === undefined && replicas === undefined) {
// Nothing to update, just return the current pool
return this.getPool(name);
}
const url = `${this._baseUrl}/pools/${encodeURIComponent(name)}`;
const payload = {};
if (newName !== undefined) {
payload.name = newName;
}
if (replicas !== undefined) {
payload.replicas = replicas;
}
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(`Pool '${name}' not found`, "pool");
}
if (response.status === 409) {
await handleConflictError(response, "pool");
}
await handlePoolError(response);
}
return (await response.json());
}
/**
* Delete a Pool.
*
* This will terminate all sandboxes in the pool.
*
* @param name - Pool name.
* @throws LangSmithResourceNotFoundError if pool not found.
*/
async deletePool(name) {
const url = `${this._baseUrl}/pools/${encodeURIComponent(name)}`;
const response = await this._fetch(url, { method: "DELETE" });
if (!response.ok) {
if (response.status === 404) {
throw new LangSmithResourceNotFoundError(`Pool '${name}' not found`, "pool");
}
await handleClientHttpError(response);
}
}
// =========================================================================
// Sandbox Operations
// =========================================================================
/**
* Create a new Sandbox.
*
* Remember to call `sandbox.delete()` when done to clean up resources.
*
* @param templateName - Name of the SandboxTemplate to use.
* @param options - Creation options.
* @returns Created Sandbox.
* @throws ResourceTimeoutError if timeout waiting for sandbox to be ready.
* @throws SandboxCreationError if sandbox creation fails.
*
* @example
* ```typescript
* const sandbox = await client.createSandbox("python-sandbox");
* try {
* const result = await sandbox.run("echo hello");
* console.log(result.stdout);
* } finally {
* await sandbox.delete();
* }
* ```
*/
async createSandbox(templateName, options = {}) {
const { name, timeout = 30, waitForReady = true } = options;
const url = `${this._baseUrl}/boxes`;
const payload = {
template_name: templateName,
wait_for_ready: waitForReady,
};
if (waitForReady) {
payload.timeout = timeout;
}
if (name) {
payload.name = name;
}
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) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`;
const response = await this._fetch(url);
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));
}
/**
* Update a sandbox's display name.
*
* @param name - Current sandbox name.
* @param newName - New display name.
* @returns Updated Sandbox.
* @throws LangSmithResourceNotFoundError if sandbox not found.
* @throws LangSmithResourceNameConflictError if newName is already in use.
*/
async updateSandbox(name, newName) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`;
const payload = { name: newName };
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(`Sandbox name '${newName}' already 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) {
const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/status`;
const response = await this._fetch(url);
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("python-sandbox", { waitForReady: false });
* // ... do other work ...
* const readySandbox = await client.waitForSandbox(sandbox.name);
* ```
*/
async waitForSandbox(name, options = {}) {
const { timeout = 120, pollInterval = 1.0 } = options;
const deadline = Date.now() + timeout * 1000;
let lastStatus = "provisioning";
while (Date.now() < deadline) {
const statusResult = await this.getSandboxStatus(name);
lastStatus = statusResult.status;
if (statusResult.status === "ready") {
return this.getSandbox(name);
}
if (statusResult.status === "failed") {
throw new LangSmithResourceCreationError(statusResult.status_message ?? `Sandbox '${name}' creation failed`, "sandbox");
}
// Wait before polling again
await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
}
throw new LangSmithResourceTimeoutError(`Sandbox '${name}' did not become ready within ${timeout}s`, "sandbox", lastStatus);
}
}