-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(sdk): add an E2B client to the Code Interpreter SDKs #1783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
d0244aa
2e2d3a8
a3bce7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| '@e2b/code-interpreter': minor | ||
| '@e2b/code-interpreter-python': minor | ||
| e2b: patch | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| 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. | ||
|
|
||
| Binding the configuration to a class now lives on the resource classes themselves (internally), so the core and the downstream clients share one implementation instead of each package generating bound subclasses on its own. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { ConnectionOpts, 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) { | ||
| this.Sandbox = Sandbox.withOpts(opts) | ||
| this.Volume = Volume.withOpts(opts) | ||
| this.Template = Template.withOpts(opts) | ||
| this.Secret = Secret.withOpts(opts) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| 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 | ||
| 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, | ||
| 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() | ||
|
Check failure on line 125 in packages/code-interpreter-js/tests/client.test.ts
|
||
|
|
||
| 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() | ||
|
Check failure on line 142 in packages/code-interpreter-js/tests/client.test.ts
|
||
|
|
||
| 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({ | ||
|
Check failure on line 153 in packages/code-interpreter-js/tests/client.test.ts
|
||
| 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({ | ||
|
Check failure on line 165 in packages/code-interpreter-js/tests/client.test.ts
|
||
| 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() | ||
|
Check failure on line 178 in packages/code-interpreter-js/tests/client.test.ts
|
||
| 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() | ||
|
Check failure on line 191 in packages/code-interpreter-js/tests/client.test.ts
|
||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
| }) | ||
|
|
||
| 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() | ||
|
Check failure on line 200 in packages/code-interpreter-js/tests/client.test.ts
|
||
|
|
||
| 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') | ||
|
Check failure on line 209 in packages/code-interpreter-js/tests/client.test.ts
|
||
| 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() | ||
|
Check failure on line 221 in packages/code-interpreter-js/tests/client.test.ts
|
||
|
|
||
| const sandbox = await Sandbox.create({ apiUrl }) | ||
|
|
||
| assert.equal(lastRequest().apiKey, ENV_API_KEY) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_ENV) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
T-54 — one flat entry point, and in JS "runtime values use
exportand type-only names useexport type— never mixed". This line mixes the class and the type alias in one statement; every other export in this file (and injs-sdk/src/index.ts) keeps them apart.Note the same mixed line already exists at
packages/js-sdk/src/index.ts:160from the earlier client PR — worth fixing there in this stack too rather than propagating it.