Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/shiny-clients-bind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/code-interpreter': minor
'@e2b/code-interpreter-python': minor
---

Add an `E2B` client to the Code Interpreter SDKs that binds the connection configuration explicitly, so the API key and domain no longer have to come from the environment variables: `new E2B({ apiKey, domain }).Sandbox.create()` in JavaScript and `E2B(api_key=..., domain=...).Sandbox.create()` in Python. The client exposes the package's own `Sandbox` (and `AsyncSandbox` in Python) together with the core `Volume`, `Template` and `Secret` resources, per-call options still take precedence, and multiple clients are isolated from each other and from the env-configured top-level exports.
28 changes: 26 additions & 2 deletions packages/code-interpreter-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,32 @@ const execution = await sbx.runCode('x+=1; x')
console.log(execution.text) // outputs 2
```

### 4. Check docs
### 4. Bind the configuration to a client

The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create an `E2B` client and use the resources it exposes:

```ts
import { E2B } from '@e2b/code-interpreter'

const client = new E2B({ apiKey: 'e2b_***', domain: 'e2b.dev' })

const sbx = await client.Sandbox.create()
const execution = await sbx.runCode('x = 1; x += 1; x')

// The core resources are bound to the client's configuration as well.
const volume = await client.Volume.create('my-volume')
const exists = await client.Template.exists('my-template')
await client.Secret.create('openai-api-key', 'sk-***')

// The classes can be destructured and used like the top-level ones.
const { Sandbox } = client
const paginator = Sandbox.list()
```

Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports.

### 5. Check docs
Visit [E2B documentation](https://docs.e2b.dev/?utm_source=npm&utm_medium=referral&utm_campaign=readme&utm_content=code-interpreter).

### 5. E2B cookbook
### 6. E2B cookbook
Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks.
93 changes: 93 additions & 0 deletions packages/code-interpreter-js/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ConnectionOpts, E2B as CoreE2B, Secret, Template, Volume } from 'e2b'

import { Sandbox } from './sandbox'

/**
* Connection options bound to an {@link E2B} client.
*
* Same as {@link ConnectionOpts} without `signal`, which cancels a single
* request and therefore can only be passed per call.
*/
export type E2BClientOpts = Omit<ConnectionOpts, 'signal'>

/**
* E2B client with an explicitly bound connection configuration.
*
* The resources exposed by the client ({@link E2B.Sandbox},
* {@link E2B.Volume}, {@link E2B.Template}, {@link E2B.Secret}) behave exactly
* like the top-level `Sandbox` / `Volume` / `Template` / `Secret` exports,
* except the options passed to the client are used as the defaults instead of
* the environment variables.
* Per-call options still take precedence over the client's options.
*
* Multiple clients are fully isolated from each other and from the top-level
* env-configured exports.
*
* @example
* ```ts
* import { E2B } from '@e2b/code-interpreter'
*
* const client = new E2B({ apiKey: 'e2b_...', domain: 'e2b.dev' })
*
* const sandbox = await client.Sandbox.create()
* const execution = await sandbox.runCode('x = 1; x += 1; x')
* ```
*/
export class E2B {
/**
* Code Interpreter `Sandbox` class bound to this client's connection
* configuration.
*/
readonly Sandbox: typeof Sandbox

/**
* `Volume` class bound to this client's connection configuration.
*/
readonly Volume: typeof Volume

/**
* `Template` bound to this client's connection configuration. Both the
* builder (`client.Template()`) and the statics
* (`client.Template.build(...)`, `client.Template.exists(...)`, …) work like
* the top-level `Template`.
*/
readonly Template: typeof Template

/**
* `Secret` class bound to this client's connection configuration.
*/
readonly Secret: typeof Secret

/**
* Create a new client with the connection options bound to it.
*
* @param opts connection options used as the defaults for every call made
* through this client's resource classes.
*/
constructor(opts?: E2BClientOpts) {
// Options are copied so later mutations of the caller's object cannot
// change the bound configuration. `signal` is dropped rather than only
// typed away, since it cancels a single request and a caller passing a
// wider-typed object (or plain JS) would otherwise bind it to every call.
const boundOpts: E2BClientOpts = { ...(opts ?? {}) }
delete (boundOpts as ConnectionOpts).signal

if (boundOpts.headers) {
boundOpts.headers = { ...boundOpts.headers }
}
if (boundOpts.apiHeaders) {
boundOpts.apiHeaders = { ...boundOpts.apiHeaders }
}

this.Sandbox = class extends Sandbox {
protected static override readonly boundOpts = boundOpts
}

// The resources that are not specific to the Code Interpreter are bound by
// the core client.
const core = new CoreE2B(boundOpts)
this.Volume = core.Volume
this.Template = core.Template
this.Secret = core.Secret
}
}
2 changes: 2 additions & 0 deletions packages/code-interpreter-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from 'e2b'

export { E2B } from './client'
export type { E2BClientOpts } from './client'
export { Sandbox } from './sandbox'
export type {
Context,
Expand Down
244 changes: 244 additions & 0 deletions packages/code-interpreter-js/tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import {
createServer,
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
import { AddressInfo } from 'node:net'
import { afterAll, assert, beforeAll, beforeEach, test } from 'vitest'

import { E2B, Sandbox, Volume } from '../src'

const API_KEY_A = `e2b_${'a'.repeat(40)}`
const API_KEY_B = `e2b_${'b'.repeat(40)}`
const ENV_API_KEY = `e2b_${'e'.repeat(40)}`

const DOMAIN_A = 'client-a.test'
const DOMAIN_B = 'client-b.test'
const DOMAIN_ENV = 'env.test'

interface RecordedRequest {
path: string
apiKey?: string
headers: Record<string, string | string[] | undefined>
body: unknown
}

let requests: RecordedRequest[] = []
let server: Server
let apiUrl: string

const lastRequest = () => requests[requests.length - 1]
const apiKeys = () => requests.map((request) => request.apiKey)

async function handler(req: IncomingMessage, res: ServerResponse) {
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(chunk as Buffer)
}
const raw = Buffer.concat(chunks).toString()

requests.push({
path: req.url ?? '',
apiKey: (req.headers['x-api-key'] as string | undefined) ?? undefined,
headers: { ...req.headers },
body: raw ? JSON.parse(raw) : undefined,
})

const respond = (status: number, body: unknown) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}

const path = req.url ?? ''
if (path.startsWith('/sandboxes')) {
respond(201, {
sandboxID: 'test-sandbox-id',
templateID: 'base',
envdVersion: '0.2.4',
})
} else if (path.startsWith('/volumes')) {
respond(201, {
volumeID: 'test-volume-id',
name: 'test-volume',
token: 'test-volume-token',
})
} else if (path.startsWith('/templates/aliases/')) {
respond(200, { aliases: [], templateID: 'test-template-id' })
} else if (path.startsWith('/secrets')) {
respond(201, {
secretID: 'test-secret-id',
name: 'test-secret',
currentVersion: 1,
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
} else {
respond(404, { code: 404, message: 'not found' })
}
}

const envBackup: Record<string, string | undefined> = {}
const envOverrides: Record<string, string | undefined> = {
E2B_API_KEY: ENV_API_KEY,
E2B_DOMAIN: DOMAIN_ENV,
E2B_API_URL: undefined,
E2B_SANDBOX_URL: undefined,
E2B_DEBUG: undefined,
}

beforeAll(async () => {
for (const [key, value] of Object.entries(envOverrides)) {
envBackup[key] = process.env[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

server = createServer(handler)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})

afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
)

for (const [key, value] of Object.entries(envBackup)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
})

beforeEach(() => {
requests = []
})

test('client.Sandbox.create uses the client config instead of env vars', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
// The Code Interpreter template is still the default.
assert.equal(
(lastRequest().body as { templateID: string }).templateID,
'code-interpreter-v1'
)
})

test('client.Sandbox instances keep the Code Interpreter API', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

assert.notEqual(client.Sandbox, Sandbox)
assert.isTrue(client.Sandbox.prototype instanceof Sandbox)

const sandbox = await client.Sandbox.create()

assert.instanceOf(sandbox, Sandbox)
assert.instanceOf(sandbox, client.Sandbox)
assert.isFunction(sandbox.runCode)
assert.isFunction(sandbox.createCodeContext)
})

test('per-call options take precedence over the client config', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create({
apiKey: API_KEY_B,
domain: DOMAIN_B,
})

assert.deepEqual(apiKeys(), [API_KEY_B])
assert.equal(sandbox.sandboxDomain, DOMAIN_B)
})

test('per-call options explicitly set to undefined keep the client config', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create({
apiKey: undefined,
domain: undefined,
})

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
})

test('two clients with different configs stay isolated', async () => {
const clientA = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
const clientB = new E2B({ apiKey: API_KEY_B, domain: DOMAIN_B, apiUrl })

const sandboxA = await clientA.Sandbox.create()
const sandboxB = await clientB.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_B])
assert.equal(sandboxA.sandboxDomain, DOMAIN_A)
assert.equal(sandboxB.sandboxDomain, DOMAIN_B)
})

test('mutating the options object does not change the bound config', async () => {
const opts = { apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }
const client = new E2B(opts)
opts.apiKey = API_KEY_B

await client.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
})

test('mutating the header map does not change the bound config', async () => {
const headers = { 'X-Test': 'bound' }
const client = new E2B({
apiKey: API_KEY_A,
domain: DOMAIN_A,
apiUrl,
headers,
})
headers['X-Test'] = 'mutated'

await client.Sandbox.create()

assert.equal(lastRequest().headers['x-test'], 'bound')
})

test('client.Sandbox can be rebound to a variable', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
const S = client.Sandbox

const sandbox = await S.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
})

test('the core resources are bound to the client config as well', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const volume = await client.Volume.create('test-volume')
assert.instanceOf(volume, Volume)
assert.deepEqual(apiKeys(), [API_KEY_A])

assert.isTrue(await client.Template.exists('test-template'))
await client.Secret.create('test-secret', 'value')

assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_A, API_KEY_A])
})

test('the top-level Sandbox keeps using the environment configuration', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
await client.Sandbox.create()

const sandbox = await Sandbox.create({ apiUrl })

assert.equal(lastRequest().apiKey, ENV_API_KEY)
assert.equal(sandbox.sandboxDomain, DOMAIN_ENV)
})
Loading
Loading